geo3d swaps a polygon that encloses no area for an empty shape, which contains no point. The query code then reads that shape's bounding box, which is empty, so all six of its coordinates are null, and unboxes them without a check.
Geo3DPoint(modulelucene-spatial3d) stores each point as x, y, z on the planet's surface (the WGS84 model by default), not as latitude and longitude.newPolygonQueryturns each lat/lonPolygoninto a geo3d shape. Each edge lies on a plane through two corners and the planet's centre, so it is an arc of a great circle.- At search time the query takes the shape's x, y, z bounding box and uses it as a cheap first test, for index cells and for each point; only points inside the box are tested against the shape.
null, the empty shape and its six null bounds, the unboxing at line 48, and the three-line guard that makes the query match nothing. Select the still to open it at full size. Generated with AI, checked against the code.- Index one document with a
Geo3DPointat lat 0.5, lon 0.5. Buildnew Polygon(lats {0, 0, 0, 0}, lons {0, 1, 2, 0}): three corners on the equator.Polygonaccepts it, andLatLonPoint.newPolygonQueryreturns 0 hits for it. Geo3DPoint.newPolygonQuery("point", WGS84, polygon)accepts it too. geo3d sees that the ring encloses no area (// Polygon is degenerate) and swaps in an empty shape,GeoCompositePolygon: {[]}, which contains no point.- You expect
searcher.count(query)to be 0. Lift the middle corner to latitude 2 and the same call returns 1, the document inside the triangle. - You get a
NullPointerException("XYZBounds.getMinimumX()is null") atPointInShapeIntersectVisitor.java:48, with assertions on and off. The query asks the empty shape for its bounding box, gets six nulls, and unboxes them. - The same happens with
newShapeQuery(field, new GeoCompositePolygon(WGS84)). Passed toIndexWriter.deleteDocuments, the query makes the next commit throw and close the writer, and the documents added since the last commit are lost.
Applications that index Geo3DPoint (opt-in spatial3d module) and build polygon queries from client or imported coordinates. Only polygons whose every ring encloses no area trigger it: a point or line sent as a polygon, a collapsed ring, corners on one great circle. Default settings, one document. The request fails loudly; a delete-by-query also closes the writer.
Mechanism, terms and numbers644 words
Geo3DPoint.newPolygonQuery converts each org.apache.lucene.geo.Polygon with GeoPolygonFactory.makeGeoPolygon, which documents that it returns null "if a valid polygon cannot be generated from this input". That is the case for a ring that encloses no area on the sphere, for example three vertices on the equator: all three lie in the equator's plane, which passes through the planet's centre, and the factory's coplanarity filter finds no valid polygon ("everything was coplanar").
Geo3DUtil.fromPolygon handles that null on purpose: under the comment // Polygon is degenerate it substitutes an empty GeoCompositePolygon, which contains no point. The query then computes the shape's bounding box. An empty composite adds nothing, so all six boxed Double bounds stay null, and the PointInShapeIntersectVisitor constructor unboxes them (PointInShapeIntersectVisitor.java:48 on main 645d4fe14a20). Every search or count that reaches the query's scorer on a segment that has the field throws, with assertions on and off. newShapeQuery with an empty GeoCompositePolygon fails the same way. The same Polygon gives 0 hits with LatLonPoint.newPolygonQuery and is rejected with IllegalArgumentException by newLargePolygonQuery. The failure has been there since 6.1.0 (LUCENE-7192, 2016).
- Entry.
Geo3DPoint.newPolygonQuerycallsGeo3DUtil.fromPolygon(planetModel, polygons)and passes the shape tonewShapeQuery(Geo3DPoint.java:168-172), which constructsPointInGeo3DShapeQuery(:241-243). - The factory says "no polygon". The private
Geo3DUtil.fromPolygon(PlanetModel, Polygon)copies the vertices intoGeoPoints and callsGeoPolygonFactory.makeGeoPolygon(planetModel, points, holeList)(Geo3DUtil.java:197-227, call at:224).makeGeoPolygonreturnsnullwhenfilterPoints(identical points, too few points) orfilterEdges(coplanar points) leaves no valid polygon (GeoPolygonFactory.java:310-320), as its javadoc says (:275-276). For the equator ringfilterPointskeeps 3 points andfilterEdgesreturnsnull("No path found. This means that everything was coplanar.",:701): the three points all have z = 0: one plane through the planet's centre, a great circle on a sphere (executed). - Deliberate substitution.
Geo3DUtil.fromPolygon(PlanetModel, Polygon...)maps anullsingle polygon tonew GeoCompositePolygon(planetModel)under// Polygon is degenerate(Geo3DUtil.java:77-84). For a multi-polygon it skipsnullcomponents (:86-93): a multi-polygon made only of such rings is also an empty composite, while a degenerate ring next to a valid one is just dropped and the query works (executed). - The empty shape is right by itself.
GeoBaseCompositeShape.isWithinloops over zero shapes and returnsfalse(GeoBaseCompositeShape.java:104-111);getBoundsadds nothing (:134-138). - Null bounds. The query constructor computes
shapeBounds = new XYZBounds(); shape.getBounds(shapeBounds);(PointInGeo3DShapeQuery.java:53-58).XYZBoundskeeps its six bounds as boxedDoublefields that startnull(XYZBounds.java:35-51, "Construct an empty bounds object",:53-54); for the empty composite all six staynull(executed). - The unboxing.
scorerSupplierreturns early only when the segment has no point values for the field (PointInGeo3DShapeQuery.java:78-81); otherwise it buildsnew PointInShapeIntersectVisitor(result, shape, shapeBounds)(:107). The visitor constructor passesbounds.getMinimumX()and the other five getters toDocValueEncoder.roundDownX(double)and its siblings (PointInShapeIntersectVisitor.java:44-54;PlanetModel.java:771), which unboxesnullat:48. Nothing on this path checks fornull. - Elsewhere
nullbounds mean "empty".GeoComplexPolygonsays "If we have no bounds at all then the answer is \"false\"" (GeoComplexPolygon.java:776-784). The query and the visitor have no such check.
Every ring of the query encloses no area on the sphere: three collinear vertices on the equator or on a meridian; all vertices identical; only two distinct vertices; an A-B-A backtrack; a multi-polygon made only of such rings; and a triangle that is valid and not self-crossing in lat/lon terms but whose vertices lie on one great circle (lat {0, 45.00000000000001, 37.76124390703503, 0}, lon {0, 90, 50.76847951640774, 0}, either winding), which no 2D area check would catch. Also newShapeQuery(field, new GeoCompositePolygon(planetModel)). Not triggered: a degenerate ring next to a valid polygon, a degenerate hole (dropped, count 1), and a segment without the field (count 0).
Exposure and likelihood267 words
- Who is hit. Code that indexes
Geo3DPoint(@lucene.experimental,Geo3DPoint.java:42) and buildsnewPolygonQueryfrom coordinates it does not control, or callsnewShapeQuerywith aGeoCompositePolygonthat can end up empty. Default WGS84 model, codec andIndexWriterConfig; the module is opt-in. - What goes wrong on search. The request fails with an internal
NullPointerException, not a clear argument error, so a server is likely to report it as its own failure. It is thrown before any point is visited: no hang, no extra or missing hits, no index change, and the query cache still answers a normal query afterwards (executed: 20 rounds on a 4-thread searcher with intra-segment partitions). Inside aBooleanQuerythe clause makessearchthrow too. - Delete path, wider effect (executed on main).
IndexWriter#deleteDocuments(query)returns normally because the query is buffered. The nextcommit()orDirectoryReader.open(writer)throws the same NPE, the writer closes with it as its tragic exception, and the document added after the last commit is lost (2 added, 1 in the last commit). The last commit stays readable and CheckIndex-clean. This is IndexWriter's general rule that an exception while applying a buffered delete is tragic (LUCENE-7868, 2017), not a second bug; with the guard fix the writer stays open. - Not triggered. A degenerate ring next to a valid polygon, a degenerate hole, or a segment without the field.
- Scale. None: one document, one query, deterministic.
- How likely: rare. Ordinary polygons never trigger it; the recorded searches found no user report in ten years; a read-only GitHub code search (not exhaustive) found no use of
Geo3DPointin Solr, Elasticsearch or OpenSearch.
One JUnit method on the public API: one Geo3DPoint document, the equator polygon, assertEquals(0, searcher.count(query)). In-memory directory, default IndexWriterConfig, a plain new IndexSearcher(reader); single thread, deterministic, assertions on or off (the test framework still picks a random similarity, locale and time zone; every recorded seed fails the same way).
/*
* 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.document.Document;
import org.apache.lucene.geo.Polygon;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.Query;
import org.apache.lucene.spatial3d.geom.PlanetModel;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestGeo3DDegeneratePolygonQuery extends LuceneTestCase {
public void testDegeneratePolygonMatchesNothing() throws Exception {
try (ByteBuffersDirectory dir = new ByteBuffersDirectory();
IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
Document doc = new Document();
doc.add(new Geo3DPoint("point", PlanetModel.WGS84, 0.5, 0.5));
w.addDocument(doc);
try (DirectoryReader reader = DirectoryReader.open(w)) {
// vertices on the equator, built as an empty GeoCompositePolygon: expected 0 hits, actual NPE
Polygon degenerate = new Polygon(new double[] {0, 0, 0, 0}, new double[] {0, 1, 2, 0});
Query q = Geo3DPoint.newPolygonQuery("point", PlanetModel.WGS84, degenerate);
assertEquals(0, new IndexSearcher(reader).count(q));
}
}
}
}
cp $REPRO/TestGeo3DDegeneratePolygonQuery.java $LUCENE/lucene/spatial3d/src/test/org/apache/lucene/spatial3d/
cd $LUCENE && ./gradlew :lucene:spatial3d:test --tests org.apache.lucene.spatial3d.TestGeo3DDegeneratePolygonQuery -Ptests.seed=DEADBEEF
- Expected
searcher.count(query)returns 0: the empty shape contains no point.- Observed
NullPointerException("XYZBounds.getMinimumX()is null") atPointInShapeIntersectVisitor.java:48, called fromPointInGeo3DShapeQuery.java:107.
More files, environment, full output211 words
The test sits in the spatial3d test package like the upstream tests. The one document is needed: in a segment without the field, scorerSupplier returns before it reaches the visitor. It asserts 0 hits, the outcome the guard fix gives, so it is also the regression test for that fix; if maintainers choose to reject the polygon instead, its last two lines become expectThrows(IllegalArgumentException.class, () -> Geo3DPoint.newPolygonQuery(...)).
It needs the org.apache.lucene.tests packages, so it compiles on 9.1.0 and later; releases 6.1.0 to 9.0.0 were checked with a separate history probe on the public API. The test's line numbers in the output include the 16-line license header.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT): fails; also fails on the 10.6.0, 10.5.2 and 9.12.3 release jars |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux aarch64 (plain Java logic, nothing platform-specific) |
cp $REPRO/TestGeo3DDegeneratePolygonQuery.java $LUCENE/lucene/spatial3d/src/test/org/apache/lucene/spatial3d/
cd $LUCENE && ./gradlew :lucene:spatial3d:test --tests org.apache.lucene.spatial3d.TestGeo3DDegeneratePolygonQuery -Ptests.seed=DEADBEEF
TestGeo3DDegeneratePolygonQuery > testDegeneratePolygonMatchesNothing FAILED
java.lang.NullPointerException: Cannot invoke "java.lang.Double.doubleValue()" because the return value of "org.apache.lucene.spatial3d.geom.XYZBounds.getMinimumX()" is null
at __randomizedtesting.SeedInfo.seed([DEADBEEF:399007E39E65274F]:0)
at org.apache.lucene.spatial3d.PointInShapeIntersectVisitor.<init>(PointInShapeIntersectVisitor.java:48)
at org.apache.lucene.spatial3d.PointInGeo3DShapeQuery$1.scorerSupplier(PointInGeo3DShapeQuery.java:107)
at org.apache.lucene.core@11.0.0-SNAPSHOT/org.apache.lucene.search.LRUQueryCache$CachingWrapperWeight.scorerSupplier(LRUQueryCache.java:874)
at org.apache.lucene.core@11.0.0-SNAPSHOT/org.apache.lucene.search.IndexSearcher.searchLeaf(IndexSearcher.java:842)
at org.apache.lucene.core@11.0.0-SNAPSHOT/org.apache.lucene.search.IndexSearcher.search(IndexSearcher.java:814)
...
at org.apache.lucene.core@11.0.0-SNAPSHOT/org.apache.lucene.search.IndexSearcher.count(IndexSearcher.java:528)
at org.apache.lucene.spatial3d.TestGeo3DDegeneratePolygonQuery.testDegeneratePolygonMatchesNothing(TestGeo3DDegeneratePolygonQuery.java:42)
:lucene:spatial3d:test (FAILURE): 1 test, 1 failure
# standalone runner, seed DEADBEEF: main -ea and -da, 10.6.0, 10.5.2: 1 of 1 fails at PointInShapeIntersectVisitor.java:48; 9.12.3: at :47- The code handles this case on purpose: a degenerate polygon becomes an empty
GeoCompositePolygon(// Polygon is degenerate,Geo3DUtil.java:79-81), which contains no point. newPolygonQuerydocuments no exception,Polygonaccepts the ring, andLatLonPointreturns 0 hits.- The query assumes bounds are never null: "They are not expected to be null" (Karl Wright, who wrote both code paths, in #7757).
The full argument400 words
- Deliberate branch.
Geo3DUtil.fromPolygonbuilds an emptyGeoCompositePolygonunder// Polygon is degenerate(Geo3DUtil.java:79-81) and skips degenerate components of a multi-polygon (:89-91). An empty composite contains no point (GeoBaseCompositeShape.java:104-111). That is consistent with "match nothing". - The factory's
nullis documented (GeoPolygonFactory.java:275-276), and Karl Wright confirmed on #9568 (2018-10-05) that "It's actually in the GeoPolygonFactory contract that null is returned for polygons that cannot be constructed". Five minutes earlier on the same issue he had written "I agree that the factory should never return null; it should return IllegalArgumentException instead. This would have to be a fix in the factory.", which supports the reject fix as much as it explains thenull. - The query assumes non-null bounds. When the predecessor query (
bkdtree3dPointInGeo3DShapeQuery$1.scorer, on the LUCENE-6699 branch) hit an NPE from null bounds in 2015 (#7757), Karl Wright answered "They are not expected to be null" and fixed the shapes. The empty-composite branch came later (LUCENE-7192, 2016) and breaks that assumption. - Contract.
newPolygonQuery's javadoc says the polygon "should have a limited number of edges (less than 100) and be well-defined, with well-separated vertices" (Geo3DPoint.java:157-159): advice, with no@throws.newShapeQuerystates no precondition (:235-243).Polygonchecks only length, closure and coordinate ranges (Polygon.java:77-101). - Siblings.
LatLonPoint.newPolygonQueryreturns 0 hits for the samePolygon;newLargePolygonQueryrejects it when the query is built (IllegalArgumentException: cannot find a point that is inside the polygon); the spatial-extras wrapper throwsInvalidShapeException("Invalid polygon, all points are coplanar")for the same factorynull(Geo3dShapeFactory.java:350-353, the #9568 fix). None ends in an NPE at search time. - Tests. No upstream test builds a query from a degenerate polygon or an empty composite:
TestGeo3DPoint's random polygon queries catchIllegalArgumentExceptionand draw again (TestGeo3DPoint.java:771-793). With either fix all existing spatial3d tests pass (executed). - History. Before LUCENE-7192 the three inputs run at that commit (the equator ring, the all-identical ring and the two-distinct-vertex ring) were rejected with
IllegalArgumentExceptionwhen the query was built (executed on the spatial3d sources of branch_6xb95bbb80927; the A-B-A ring and the great-circle triangle were not run there). LUCENE-7192 ("Geo3d polygon creation should not get upset about co-linear points", for OpenStreetMap data) made the factory returnnulland added the empty-composite branch to be more tolerant; neither it nor LUCENE-7221 mentions a query-time failure.
The devil's-advocate check139 words
The strongest "works as designed / misuse" case (the devil's-advocate verifier and the cross-model reviewers): the javadoc says the polygon "should be well-defined"; Polygon says it "must not be self-crossing, otherwise may result in unexpected behavior" (Polygon.java:34), and a collinear or A-B-A ring retraces itself; maintainers prefer rejecting degenerate polygons (#9568, newLargePolygonQuery, the Tessellator's "at least three non-collinear points required", TestTessellator.java:200-207); and Geo3DPoint is @lucene.experimental.
Why it fails: all of that supports at most a clear IllegalArgumentException when the query is built, not an NPE at search time; the great-circle triangle does not self-cross in lat/lon terms and still throws; @lucene.experimental is about API stability, not a licence to crash; the code explicitly accepts and converts this input; and newShapeQuery with an empty composite involves no polygon at all. Verdict: not intended, a defect.
Recommended Match nothing: return no scorer when the shape has no bounds
Return no scorer when the shape added nothing to its bounding box: three lines in PointInGeo3DShapeQuery.scorerSupplier, if (shapeBounds.getMinimumX() == null) return null;. Degenerate polygons and empty composites then match nothing, like LatLonPoint. The minimal test passes, and so does ./gradlew :lucene:spatial3d:test (182 tests, 3 skipped, no failure).
if (shapeBounds.getMinimumX() == null) {
// The shape added nothing to its bounds (e.g. the empty GeoCompositePolygon that
// Geo3DUtil builds for a degenerate polygon): it contains no point.
return null;
}All options and trade-offs2 options · 575 words
Both options were validated with gradle in a fresh worktree at 645d4fe14a20 (seed DEADBEEF). They differ in what a degenerate polygon means: no hits, or an error when the query is built. Both remove the NullPointerException for newPolygonQuery; only the guard also covers newShapeQuery with an empty composite, so the two can be combined.
Match nothing: return no scorer when the shape has no bounds (validated by the checks) (recommended)
In PointInGeo3DShapeQuery's scorerSupplier, right after the existing values == null check (PointInGeo3DShapeQuery.java:79-81). Returning null is the usual "no matches in this segment" answer, so the query cache and the delete path need nothing else.
if (shapeBounds.getMinimumX() == null) {
// The shape added nothing to its bounds (e.g. the empty GeoCompositePolygon that
// Geo3DUtil builds for a degenerate polygon): it contains no point.
return null;
}Trade-offs
- Silent: a client that sends a broken polygon gets no hits and no error, as with
LatLonPoint. - The guard checks
getMinimumX()only, as the original sketch does. That is enough for the empty composite (all six staynull), butXYZBoundssets each axis separately (addXValue,addYValue,addZValue,XYZBounds.java:317,:340,:363), so checking all six bounds is equally cheap and more defensive.
What the checks validated
- Gradle, fresh worktree at
645d4fe14a20, seed DEADBEEF: the minimal test fails before the change (1 test, 1 failure) and passes after it, with assertions on and with-Ptests.asserts=false;./gradlew :lucene:spatial3d:testpasses (182 tests: the module's 181 plus the new one, 3 skipped). - With the same guard on the stock jars: an independent six-test suite (two controls, equator and meridian rings, an all-degenerate multi-polygon,
newShapeQuerywith an empty composite) passes 6 of 6, upstreamTestGeo3DPointpasses (15 passed, the 2@Nightlytests skipped; assertions on), and the delete-path probe keeps the writer open and commits both documents.
In the package-private varargs Geo3DUtil.fromPolygon(PlanetModel, Polygon...) (Geo3DUtil.java:71, called by Geo3DPoint.newPolygonQuery), throw instead of building an empty composite: for a single degenerate polygon, and for a multi-polygon whose components are all degenerate.
if (component == null) {
throw new IllegalArgumentException("polygon is degenerate: it encloses no area");
}
...
if (poly.size() == 0) {
throw new IllegalArgumentException("all polygons are degenerate: they enclose no area");
}Trade-offs
- Consistent with the #9568 fix in spatial-extras,
newLargePolygonQueryand the Tessellator, and with Karl Wright's 2018 "the factory should never return null; it should return IllegalArgumentException instead" (#9568); it tells the caller what is wrong. - Does not cover
newShapeQuery(field, new GeoCompositePolygon(pm)), which never passes throughfromPolygon; the guard is still needed for that path. - Turns a call that works today into an error: on an index whose segments have no
Geo3DPointvalues the query currently returns 0 hits (executed). - Keeps the mixed case working (a degenerate ring next to a valid one is still dropped); a stricter variant that rejects every degenerate component would also break that case and degenerate holes (not run).
- Also changes the other caller of
fromPolygon,Geo3DDocValuesField.newOutsidePolygonSort(Geo3DDocValuesField.java:311-313): a degenerate polygon would throw there too when the sort is built (not run).
What the checks validated
- Gradle, same worktree:
newPolygonQuerythrowsIllegalArgumentException: polygon is degenerate: it encloses no areafor the repro's input (so the minimal test, which asserts 0 hits, fails by design);./gradlew :lucene:spatial3d:testwithout the minimal test passes (181 tests, 3 skipped).
A read-only GitHub code search (not exhaustive) found no use of Geo3DPoint in Solr, Elasticsearch or OpenSearch (Solr's geo3d support goes through spatial-extras, which has thrown InvalidShapeException for such polygons since #9568), so only applications that build Geo3DPoint queries directly are exposed.
Found by reading the spatial3d query code while setting up a test harness for its geometry: the explicit degenerate-polygon branch produces a shape with no bounding box, and the query unboxes that box unconditionally. A public-API test then confirmed it. No formal method was involved.
The method in full120 words
- Method: code reading (not a formal method). While building a metamorphic test harness for the spatial3d geometry code, an AI tool noticed the explicit
// Polygon is degeneratebranch inGeo3DUtil.fromPolygonand, separately, the unconditional unboxing ofXYZBoundsin thePointInShapeIntersectVisitorconstructor, and asked what an empty shape's bounds are. - It ran a set of degenerate but legal polygons on the stock main jar, then wrote a public-API JUnit test, which failed on main with assertions on and off (20 of 20 iterations) and on 10.5.2 and 9.12.3, and passed with the null-bounds guard.
- Independent checkers then reproduced it with their own tests, widened the trigger set (the great-circle triangle,
newShapeQuerywith an empty composite) and found the delete-path effect.
No earlier report found (searched 2026-10-26, re-checked 2026-10-30) in GitHub issues and PRs including the JIRA archive, the mailing lists, downstream projects or the web. Closest, not this bug: #9568 (the same pattern in spatial-extras, fixed only there in 2018) and #7757 (a 2015 NPE at the equivalent place in the predecessor query, from wrong bounds in non-empty shapes).
Searches5 searches
No issue, PR, commit, JIRA entry, list thread, downstream issue or web page reports this NullPointerException for a degenerate Geo3DPoint polygon or an empty composite passed to newShapeQuery; the recorded searches found no report or discussion by anyone.
- #9568 / LUCENE-8522 (closed, fixed in 7.6): the same "factory returns
null, an NPE follows" pattern, in the spatial-extrasGeo3dShapeFactorypath, fixed only there; theGeo3DPointpath still fails on every later release. - #7757 / LUCENE-6699 (2015): an NPE at the equivalent place in the predecessor query (
bkdtree3dPointInGeo3DShapeQuery$1.scorer), from wronggetBoundsin non-empty shapes, before the empty-composite branch existed. - #9893 / LUCENE-8850 (open) and apache/lucene-solr#709 (unmerged): reject zero-area
Polygons in core. That would cover the equator, meridian, identical-vertex and A-B-A rings, but not the great-circle triangle ornewShapeQuery. - #8247 / LUCENE-7192: the origin of the tolerant branch.
- Open PRs: two touch
PointInGeo3DShapeQuery.java, both only at theDocIdSetBuilderline (:105), neither in the bounds handling: #16847 (maxDoc()tomaxLeafDoc()) and the stale #709 (LUCENE-10311, open since 2022-02-24). No open PR touchesPointInShapeIntersectVisitor.javaorGeo3DUtil.java.
Gaps: GitHub search matches tokens and was quota-limited; list search matches subjects and leading text; private reports cannot be searched.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive, apache/lucene-solr, apache/solr, Elasticsearch, OpenSearch) | 2026-10-26 | three hunt prior-art runs (36 issue/PR, 7 commit and 7 downstream queries: the error text, XYZBounds, PointInShapeIntersectVisitor, makeGeoPolygon, GeoCompositePolygon, Geo3DUtil, newShapeQuery, coplanar, collinear, zero-area) plus manual searches; the changed files of the geo-related open PRs among all 413 | no report; closest #9568, #7757, #9893, lucene-solr#709, #8247 |
| JIRA (LUCENE and SOLR) | 2026-10-26 | 16 archive queries in the tool runs and 5 JQL queries by hand; comments of the candidates read | no report |
| Mailing lists (dev@, java-user@, issues@, commits@) | 2026-10-26 | 60 queries in the tool runs | no report; only commit notifications for the issues above |
| Web | 2026-10-26 | 17 queries with the error text, class names and downstream names | no relevant page |
| GitHub re-check (read-only) | 2026-10-30 | PointInShapeIntersectVisitor, XYZBounds NullPointerException, degenerate polygon Geo3DPoint, GeoCompositePolygon empty, Geo3DPoint NullPointerException, newPolygonQuery degenerate, Geo3DPoint collinear; open PRs (text search, and the changed files of all open PRs); upstream main 2f424fd428da | 1 unrelated hit (#8223); no open PR mentions the query class; two open PRs, #16847 and #709, change its DocIdSetBuilder line only; the three files unchanged upstream |
Since 6.1.0 (2016): LUCENE-7192 (75cdcbab1094, follow-up 02f1dacc3d84) made degenerate polygons map to an empty shape instead of an IllegalArgumentException, and the bounds check added by LUCENE-7221 (1d76b2c49ba9) unboxes its null bounds. Every tested release from 6.1.0 to 10.6.0, and main, fails.
Versions tested5 rows
Introduced by 75cdcbab1094 (master, 2016-04-19, Karl Wright, "LUCENE-7192: Revamp how coplanar points are detected and filtered, for OpenStreetMap compatibility."; issue #8247, committed from JIRA, no PR): GeoPolygonFactory returns null instead of throwing for degenerate input, and newPolygonQuery maps a null polygon to an empty GeoCompositePolygon. The same-day follow-up 02f1dacc3d84 lets the three-collinear-vertex input through too. 6.1.0 shipped the branch_6x backports 01a7ee4eebbc and e4012c6b32ef.
The throwing line is older: 1d76b2c49ba9 (LUCENE-7221, #8276, 2016-04-14) made the visitor compare against the shape's XYZBounds; LUCENE-7430 (08ca33f57e5, 6.3.0) moved the unboxing into the visitor constructor, and LUCENE-9150 (a6e80d004d8, 8.5.0, "Restore support for dynamic PlanetModel in spatial3d") switched it from the static Geo3DDocValuesField.roundDownX(...) to the shape's getPlanetModel().getDocValueEncoder().roundDownX(...). It never had a null check. Old releases ran on JDK 25, not the JDK of their time; the failing frame moves with the line (compare:86 in 6.1.0-6.2.1, then <init>:45 to :48).
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | the minimal test fails with gradle and with the standalone runner, assertions on and off |
| 10.6.0, 10.5.2, 9.12.3 | affected | the minimal test fails (release jars) |
| 6.1.0 - 10.5.2 (17 releases) | affected | the history probe fails on every one: 6.1.0, 6.2.0, 6.2.1, 6.3.0, 6.6.6, 7.0.0, 7.0.1, 7.1.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 (assertions off too on 6.1.0, 10.5.2 and main) |
branch_6x b95bbb80927 (just before the LUCENE-7192 backport) | rejected at build | the three degenerate polygons run there (the equator ring, the all-identical ring, two distinct vertices) are rejected with IllegalArgumentException when the query is built |
| 6.0.0, 6.0.1 | no polygon API | no newPolygonQuery; newShapeQuery with an empty composite already fails at search time, with IndexOutOfBoundsException |
An independent reproducer, a devil's advocate, an adjudication and fresh-seed re-runs confirm it; reviewers from another model family, reading only, agreed on the root cause. History runs covered 17 releases and main; both fixes pass the spatial3d tests.
Checks and verdicts310 words
Each executing check ran tests against the stock jars; the cross-model reviewers (OpenAI GPT-5.6) could not run Java, so a separate run re-ran the repro for them.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Code reading, then a public-API JUnit test that fails on main (assertions on and off, 20 of 20 iterations), 10.5.2 and 9.12.3 and passes with the guard. |
| cross-model review, refute lens (OpenAI GPT-5.6) | not real (low) | Read only; it could not run Java. It agreed with the root cause and argued (moderate) that the zero-area input is outside newPolygonQuery's "well-defined" guidance, the misuse case that "Why it is a bug" rebuts; it pointed to newShapeQuery with an empty composite as an in-contract trigger. A separate run re-ran the repro: it fails. |
| Independent reproducer | real | Its own six tests (two controls, four claims) fail 4 of 6 on main, 10.5.2, 9.12.3 and 8.11.4, with the controls passing; 6 of 6 pass with the guard. |
| Cross-model adversary, intended lens (OpenAI GPT-5.6) | real (medium) | Works-as-designed argument moderate and not holding; inputs in contract. A separate test (newShapeQuery with an empty composite) throws the same NPE. |
| Devil's advocate | real (high) | Built the misuse case and rejected it; found the great-circle triangle, the LatLonPoint contrast and the mixed and hole cases. |
| adjudication | confirmed (high) | Re-ran the independent reproduction's tests with a new seed (assertions on and off: 4 of 6 fail, controls pass); class crash; not a duplicate. |
| history, prior art, security screen | since 6.1.0; new; not security-sensitive | 17 releases plus main and commit-level runs; three prior-art runs; the delete-path probe. |
| brief (2026-10-30) | reproduced; both fixes validated | The minimal test on main 645d4fe14a20 (assertions on and off, gradle and standalone), 10.6.0, 10.5.2 and 9.12.3; the delete-path probe re-run; both fixes with the spatial3d test suite. |
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 525 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,532 words
- Registry title
- Geo3DPoint.newPolygonQuery turns a degenerate polygon (for example three vertices on the equator) into an empty GeoCompositePolygon that is meant to match nothing, but every search with the query throws NullPointerException; Geo3DPoint.newShapeQuery with an empty GeoCompositePolygon fails the same way
- Area
- spatial3d / Geo3DPoint polygon and shape queries (Geo3DUtil.fromPolygon, PointInGeo3DShapeQuery, PointInShapeIntersectVisitor)
- Priority
- Low, rank 683: Degenerate Geo3D polygon queries in the experimental spatial3d module throw a loud NPE, and only the deleteDocuments path closes the writer.
- Impact class
- crash
- Impact note
- Hits applications that use the Geo3DPoint field of the opt-in spatial3d module (@lucene.experimental; a read-only code search found no use in Elasticsearch, OpenSearch or Solr) and build a polygon query in which every component is degenerate (vertices on one great circle, all vertices identical, only two distinct vertices, or an A-B-A backtrack), or call newShapeQuery with an empty GeoCompositePolygon: with the default WGS84 model, codec and IndexWriterConfig, every search of a segment that has the field throws NullPointerException, with -ea and -da; there is no hang, no wrong hit and no index damage, and only that request fails, except that the same query passed to IndexWriter#deleteDocuments(Query) makes the next commit, NRT reader open or flush throw, closes the writer with a tragic exception and loses the documents added since the last commit (executed; the last commit stays intact and CheckIndex-clean); no scale is needed, and every release since 6.1.0 is affected.
- Summary
- You build Geo3DPoint.newPolygonQuery from an org.apache.lucene.geo.Polygon that Polygon accepts but whose vertices all lie on one great circle, for example lat {0,0,0,0} and lon {0,1,2,0}, and expect a query that matches nothing: Geo3DUtil.fromPolygon recognizes the polygon as degenerate ('// Polygon is degenerate') and deliberately replaces it with an empty GeoCompositePolygon. Instead every search with the query throws NullPointerException ('Cannot invoke Double.doubleValue() because the return value of XYZBounds.getMinimumX() is null'), because the empty shape adds nothing to its XYZBounds and the PointInShapeIntersectVisitor constructor unboxes the null bounds. Geo3DPoint.newShapeQuery with an empty GeoCompositePolygon fails the same way, while LatLonPoint.newPolygonQuery returns 0 hits for the same Polygon and newLargePolygonQuery rejects it with IllegalArgumentException when the query is built.
- Introduced
- 75cdcbab1094ce25a9a340f84f2101c5718635c0 (master, 2016-04-19, 'LUCENE-7192: Revamp how coplanar points are detected and filtered, for OpenStreetMap compatibility.', by Karl Wright (GitHub DaddyWri); LUCENE-7192 is apache/lucene#8247; committed from the JIRA issue, no pull request): GeoPolygonFactory.makeGeoPolygon and filterPoints return null instead of throwing IllegalArgumentException for degenerate input, and Geo3DPoint.newPolygonQuery maps a null polygon to an empty GeoCompositePolygon; the same-day follow-up 02f1dacc3d84a42f9359aedb0cc98ca4a6161d57 ('LUCENE-7192: Catch the case where we have two points in the same poly that are not adjacent but identical.') lets the three-collinear-vertex input through as well; 6.1.0 shipped the branch_6x backports 01a7ee4eebbc and e4012c6b32ef (identical patch-ids). The unboxing that throws is older: 1d76b2c49ba9c924b539f7d8a0f58c3e609656f1 (LUCENE-7221, #8276, 2016-04-14, backport 0b0e442010a6) made PointInShapeIntersectVisitor compare against the shape's XYZBounds; LUCENE-7430 (08ca33f57e5, 6.3.0) moved the unboxing into the visitor constructor and LUCENE-9150 (a6e80d004d8, 8.5.0) reworded it; it has never had a null check. [exec: the spatial3d sources of each probed branch_6x commit, shadowed over the 6.1.0 release jars (only the two query glue files ported to the 6.1.0 core API): b95bbb80927, just before the LUCENE-7192 backport, rejects the collinear, identical-vertex and two-vertex polygons at build time with IllegalArgumentException; 01a7ee4eebb turns the identical-vertex and two-vertex polygons into the search-time NPE; e4012c6b32e also the collinear one; read: git log -S, git blame, git show] (2016-04-19 (LUCENE-7192 and its follow-up; the unboxing site, LUCENE-7221, dates from 2016-04-14))
- First affected version
- 6.1.0 [exec] (the first release with Geo3DPoint.newPolygonQuery, org.apache.lucene.geo.Polygon and the LUCENE-7192 commits; fails with -ea and -da); 6.0.0 and 6.0.1 have no newPolygonQuery, but there the newShapeQuery(empty GeoCompositePolygon) variant already fails at search time, with IndexOutOfBoundsException in GeoCompositeMembershipShape.getEdgePoints instead of the NPE [exec]
- Affected versions
- 6.1.0 to 10.5.2 (the last release before 10.6.0) and main ea57360912ed. [exec] a version-neutral public-API main() (four API-era variants that differ only in marked API lines) fails with the same NPE for every degenerate input and for the empty-composite shape on 6.1.0 (-ea and -da), 6.2.0, 6.2.1, 6.3.0, 6.6.6, 7.0.0, 7.0.1, 7.1.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 (-ea and -da) and main (-ea and -da), with the circle, box and box-plus-degenerate-component controls passing; the finder's JUnit fails on main (-ea, -da, 20 of 20 iterations), 10.5.2 and 9.12.3, and the independent reproduction's JUnit on main, 10.5.2, 9.12.3 and 8.11.4. [read] releases between the tested points are inferred: every executed release fails, including the releases on both sides of the two later changes to the unboxing (LUCENE-7430, LUCENE-9150). Not fixed upstream as of 2026-10-26.
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- non-formal: code reading
- Upstream and prior reports
- none found (2026-10-26, follow-up prior-report record verdict new: three scripted prior-report searches (36 GitHub issue/PR, 7 commit, 7 downstream-repository, 60 mailing-list queries over dev@, java-user@, issues@ and commits@, 16 JIRA-archive and 6 web queries; 297, 372 and 275 distinct hits, 0 high overlap), manual GitHub searches across apache/lucene, apache/lucene-solr, apache/solr, elastic/elasticsearch and opensearch-project/OpenSearch, 5 JQL queries over LUCENE and SOLR plus JIRA comment reads, 8 manual web searches, the changed files of the geo-related ones among all 413 open apache/lucene PRs, and the code history); no issue, PR, JIRA entry, list thread or web page reports this NullPointerException, and spatial3d on upstream main still has no null-bounds guard. Related items, not reports of this bug: #9568 (LUCENE-8522, closed, fixed in 7.6 in spatial-extras only, commit a5665d8): the same pattern (makeGeoPolygon returns null, an NPE follows) in the sibling spatial-extras Geo3dShapeFactory path, which now throws InvalidShapeException; the Geo3DPoint path still fails on 8.11.4 to main, after that fix; #7757 (LUCENE-6699, 2015): an NPE at the same PointInGeo3DShapeQuery call site from null XYZBounds, caused then by a bounds-computation bug in non-empty shapes and fixed in the shapes, before the empty-composite branch existed (DaddyWri: bounds 'are not expected to be null'); #9893 (LUCENE-8850, open enhancement) and apache/lucene-solr#709 (open, unmerged): reject zero-area Polygons in core, which would cover part of the trigger set but not the great-circle triangle or newShapeQuery; #8247 (LUCENE-7192): the origin of the tolerant branch.
- Lucene commit tested
- main
645d4fe14a20(2026-10-30): the minimal test with gradle and with the standalone runner, assertions on and off, and both fixes with./gradlew :lucene:spatial3d:test; the minimal test on the 10.6.0, 10.5.2 and 9.12.3 release jars; the delete-path probe re-run. The history probe ran on 17 releases from 6.1.0 to 10.5.2 and on branch_6x commits around LUCENE-7192 and LUCENE-7221. Most of the hunt's verification ran on mainea57360912ed; the spatial3d sources are identical at both commits.
- Finding record
lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DUtil.java:71-95 (at ea57360912ed) (fromPolygon: degenerate single polygon -> empty GeoCompositePolygon at :78-82, '// Polygon is degenerate' at :80; null components of a multi-polygon skipped at :86-92)lucene/spatial3d/src/java/org/apache/lucene/spatial3d/PointInGeo3DShapeQuery.java:50-58 (at ea57360912ed) (shapeBounds = new XYZBounds(); shape.getBounds(shapeBounds)), :76-81 (segments without point values return null), :107 (values.intersect(new PointInShapeIntersectVisitor(result, shape, shapeBounds)))lucene/spatial3d/src/java/org/apache/lucene/spatial3d/PointInShapeIntersectVisitor.java:44-54 (at ea57360912ed) (constructor unboxes bounds.getMinimumX() .. getMaximumZ() with no null check; :48 is the throwing line)lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/XYZBounds.java:36-53 (at ea57360912ed) (boxed Double bounds start null; 'Construct an empty bounds object')lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoComplexPolygon.java:776-784 (at ea57360912ed) ('If we have no bounds at all then the answer is "false"': null bounds treated as empty elsewhere in geom)lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DPoint.java:42 (at ea57360912ed) (@lucene.experimental), :156-171 (newPolygonQuery javadoc: 'should ... be well-defined, with well-separated vertices'; no @throws), :241 (newShapeQuery)SECURITY.md lines 28 (downstream sanitizes untrusted input), 38, 42, 46 (out-of-scope issues may be public), 50 (in-scope reports), as cited in the follow-up the security record
- The finding in 30 seconds
a Lucene main checkout (645d4fe14a20) lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DPoint.java:168-172, :241-243a Lucene main checkout (645d4fe14a20) lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DUtil.java:77-93, :197-227a Lucene main checkout (645d4fe14a20) lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoPolygonFactory.java:275-276, :310-320, :677-702a Lucene main checkout (645d4fe14a20) lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoBaseCompositeShape.java:104-111, :134-138a Lucene main checkout (645d4fe14a20) lucene/spatial3d/src/java/org/apache/lucene/spatial3d/PointInGeo3DShapeQuery.java:53-58, :76-111a Lucene main checkout (645d4fe14a20) lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/XYZBounds.java:35-54a Lucene main checkout (645d4fe14a20) lucene/spatial3d/src/java/org/apache/lucene/spatial3d/PointInShapeIntersectVisitor.java:44-54; geom/PlanetModel.java:771a Lucene main checkout (645d4fe14a20) lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoComplexPolygon.java:776-784a Lucene main checkout (645d4fe14a20) lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/Plane.java:72 ("Construct a plane through two points and origin"); geom/GeoConvexPolygon.java:236-249 and geom/SidedPlane.java:59-67 (the (Vector, Vector, Vector) constructor GeoConvexPolygon uses; edge planes include the origin), for the background
- Who is affected
a Lucene main checkout (645d4fe14a20) lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DPoint.java:42
- Why it looks unintended
a Lucene main checkout (645d4fe14a20) lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DUtil.java:79-81, :89-91; geom/GeoBaseCompositeShape.java:104-111; geom/GeoPolygonFactory.java:275-276a Lucene main checkout (645d4fe14a20) lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DPoint.java:157-172, :235-243a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/geo/Polygon.java:34, :77-101a Lucene main checkout (645d4fe14a20) lucene/spatial-extras/src/java/org/apache/lucene/spatial/spatial4j/Geo3dShapeFactory.java:350-353a Lucene main checkout (645d4fe14a20) lucene/spatial3d/src/test/org/apache/lucene/spatial3d/TestGeo3DPoint.java:771-793a Lucene main checkout (645d4fe14a20) lucene/core/src/test/org/apache/lucene/geo/TestTessellator.java:200-207
- Suggested fix
a Lucene main checkout (645d4fe14a20) lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DUtil.java:35, :71 (package-private class and varargs method); Geo3DDocValuesField.java:311-313 (newOutsidePolygonSort calls fromPolygon); geom/XYZBounds.java:317, :340, :363 (per-axis setters)
- TestGeo3DDegeneratePolygonQuery.java (lucene/spatial3d, package org.apache.lucene.spatial3d, 1 test):
repro/LFV-282/src/org/apache/lucene/spatial3d/TestGeo3DDegeneratePolygonQuery.java(46 lines)
Made in parallel with this doc from the code at 645d4fe14a20 and an executed probe on the stock main jars.
assets/LFV-282/bug.png(525 KB)
