#5600 Geospatial misc: STRING stays STRING, GEOSPATIAL precision from SQL, fewer cells for area shapes - #5604
Conversation
…sion is settable from SQL, area shapes index fewer cells
Three independent geospatial findings.
1. BinarySerializer.deserializeValue sniffed the first characters of EVERY
string it read and, when they looked like the head of a WKT geometry,
returned a spatial4j Shape instead of the String that was written. A
property declared STRING did not round-trip - getString() answered
spatial4j's Pt(x=..,y=..) form, which is not valid WKT - and free text
starting with POINT/POLYGON/... was transformed too. The storage layer no
longer changes the declared type of a value. Pre-26.2.1 databases, where
shapes were stored as WKT text, keep working: GeoUtils.parseGeometry(),
GeoUtils.parseJtsGeometry() and LSMTreeGeoIndex.toShape() all accept WKT,
and the two legacy SQL methods isWithin()/intersectsWith(), which used to
rely on the sniff, now parse their operands themselves.
2. CREATE INDEX ... GEOSPATIAL METADATA {"precision": 6} was parsed, run and
silently ignored: CreateIndexStatement forwarded METADATA only for
LSM_VECTOR, FULL_TEXT and LSM_SPARSE_VECTOR. GEOSPATIAL now has a builder
of its own, TypeGeoIndexBuilder, in the same shape as the other three, so
precision and tokenization are reachable from SQL and from the Java API.
An unusable METADATA clause - unknown key, out-of-range precision, invalid
tokenization, or METADATA on an index type that has no settings - is now
an error instead of being dropped. withType() also records the type on the
original builder, so a caller that ignores the returned specialised
instance no longer hits "indexType was not specified".
3. The FRONTIER tokenizer collapses a complete set of sibling frontier cells
into their parent, recursively - the reduction Lucene calls
pruneLeafyBranches and applies by default when indexing. Measured on the
frontier cell count: 57% fewer for a small square, 74% for a jagged
outline, unchanged for a linestring or a wide rectangle. Points are
single-child chains and keep their one-token guarantee on an
allocation-free path. Unlike Lucene's implementation, which buffers the
whole decomposition in a list, this streams: only the tokens on the
current root-to-leaf path can still be revoked, bounding what is held to
subCellsSize * detailLevel. Both produce identical token sets, asserted
against Lucene directly in LSMTreeGeoIndexCellPruningTest.
|
Tick the box to add this pull request to the merge queue (same as
|
Up to standards ✅🟢 Issues
|
| Metric | Results |
|---|---|
| Complexity | 53 |
NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.
Review of #5604 - Geospatial miscThorough, disciplined PR. Three independent fixes on the geospatial path, each landed at the right layer, each with regression tests, and the description explains not just the fix but the alternative not taken. The FRONTIER prune is validated directly against Lucene's own A few observations, mostly minor: 1. STRING no longer sniffs to a Shape (BinarySerializer) - correctRemoving the
2. GEOSPATIAL precision from SQL - correct, one compat flagFixing this one level up via
3. Fewer cells for area shapes - correct, well-boundedThe streaming prune is the right answer over subclassing Lucene's buffered
SQL method fixes (isWithin/intersectsWith)Parsing both operands through TestsStrong. Round-trip identity (incl. free text starting with Nothing blocking from my side - the two items I'd most want addressed are the explicit release-note callout for the runtime METADATA-rejection change, and optionally rejecting non-integral precision. |
…TA rejection as breaking
- METADATA {"precision": 6.9} passed the instanceof Number check and was
truncated to 6. A GeoHash precision is a tree level, so truncating drops
exactly the kind of typo this validation exists to report.
- The release note now flags the runtime rejection of a METADATA clause on
an index type with no settings (CREATE INDEX ... UNIQUE METADATA {...}) as
a breaking change in its own right, not only as the geospatial angle: the
grammar still accepts such a statement, only running it is refused.
|
Thanks - addressed the two you flagged, in 0156e7d. Non-integral precision now rejected.
Not taken - the No action on the On the |
|
Review of PR 5604 - Geospatial misc Reviewed all three findings against the surrounding code. This is a well-scoped, unusually well-documented and well-tested PR. The commit messages, release notes, and Javadoc all explain why, not just what, which made the review much easier. Below are the points worth discussing; none are blockers. 1. STRING stays STRING (BinarySerializer) Removing the WKT sniff from
Nit: 2. GEOSPATIAL precision from SQL The
Maintainability note: 3. Fewer cells for area shapes (forEachPrunedFrontierCell) The streaming prune is the nicest part of the PR: bounding retained state to
Two small observations:
Tests Coverage is excellent: round-trip identity, free-text keyword false-positives, lists/maps, indexability, SQL precision/tokenization, the full rejection matrix, delete symmetry for areas, and a direct token-set equality against Lucene own Overall: LGTM. The two theoretical notes above are the only things I would consider tightening, and neither blocks merge. |
…the depth invariant actually guards - The Lucene-equivalence test compares SETS, so a token emitted twice would have been invisible there while production - which collects into a List and issues one put() per token - wrote a duplicate index entry. Assert on the raw stream instead. It passes, so no duplicate exists; the point is that it would now be caught. - The comment on `assert depth == level` implied the algorithm hinges on it. It does not: a frame's parent is the frame below it, found by pop order and not by level arithmetic, and the array bound holds because the pop loop keeps the levels on the stack strictly increasing. The assert documents the GeoHash one-character-per-level contract; say that.
|
Thanks - both notes addressed in 581087f. Duplicate emit could hide behind the set comparison. Right, and worth closing: the equivalence test compared The So there is no silent-misprune path here, and the assert is documentation of the GeoHash one-base-32-character-per-level contract rather than a guard the code leans on. I have corrected the comments to say exactly that, since the previous wording implied the dependency you (reasonably) read into it.
|
Review: #5600 Geospatial miscReviewed the full diff and traced the integration points ( What's good
Points worth flagging
Style / conventionsConsistent with the codebase and CLAUDE.md: Nice work: the reasoning captured in the code and PR description makes this easy to review with confidence. Automated review by Claude; the maintainer should confirm before merging. |
…elation methods share a base
- isWithin/intersectsWith caught every IllegalArgumentException and answered
null, so a typo in the query - coords.isWithin('POLYGONN (...)') - silently
read as "no match" and was awkward to debug. The two operands are not the
same kind of input: the parameter is written in the query, so a malformed
one is a mistake to report, exactly as a missing one already was; the value
comes from the record, so a row that holds no geometry is skipped, because
failing the whole query over one bad row would be worse.
- Both methods carried an identical copy of that parsing block. They now
share AbstractSQLGeoRelationMethod and differ only in the SpatialRelation
they accept, so the parsing contract has one definition.
- TypeGeoIndexBuilder.withMetadata(JSONObject) returns the builder, matching
the withMetadata(IndexMetadata) override next to it.
|
Thanks - all three code points addressed in 5b092c1. 2. Silent
3. Duplication. With the above the block would have grown in both copies, so extracting it was overdue. Both methods now sit on 4. 1. The 449 tests across the geospatial, |
Review: #5600 Geospatial miscReviewed all 14 files. This is a well-scoped, high-quality PR: three genuinely independent fixes on the geospatial path, each with a clear rationale and strong tests. I found no blocking bugs. Below are confirmations and a few minor observations. What is solid
Minor observations (non-blocking)
Note on verificationI could not run Overall: clean, well-reasoned, and thoroughly tested. Nice work. |
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #5604 +/- ##
==========================================
+ Coverage 66.73% 66.77% +0.03%
==========================================
Files 1705 1707 +2
Lines 140950 141071 +121
Branches 30484 30505 +21
==========================================
+ Hits 94057 94194 +137
+ Misses 34235 34203 -32
- Partials 12658 12674 +16 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Codecov flagged the patch: TypeGeoIndexBuilder at 69% and AbstractSQLGeoRelationMethod at 53%, because the Java-API surface the PR adds was only ever reached through SQL. - TypeGeoIndexBuilderTest exercises it directly: withPrecision/withTokenization reach the created index, withMetadata(JSONObject) chains and tolerates null, another index type's metadata is rejected, a setter on non-geo metadata reports the state instead of throwing ClassCastException, and withGeoType() is identity on a geo builder and refuses a non-geospatial one. - relationMethodsHandleTheDegenerateOperands covers the operand shapes the two relation methods had left untested: a missing or null parameter is a query mistake, an empty one has no geometry to relate and simply does not match. - The TypeGeoIndexBuilder(database, typeName, propertyNames) constructor had no caller. It was mirrored from TypeFullTextIndexBuilder, where it is equally unused; no reason to add dead code to a new class.
Code review (automated) - PR #5604Reviewed all 15 files. This is a well-structured, unusually well-documented change: the intent behind each decision is captured in Javadoc/comments and the test coverage is strong (identity assertion against Lucene's own pruner, coverage-never-shrinks property, delete symmetry, degenerate operands). Below are the things worth confirming, none of them blocking. Correctness - looks right
Things worth a second look
Minor / nits
TestsCoverage is excellent: the reflection-based identity check against Lucene's buffered pruner is the right way to pin this, and Overall: solid, careful work. My only real ask is a sanity check on items (1) and (3) before merge. |
…sRef, flag the snapshot rebuild - GeoUtils.parseGeometry cast a point map's x/y straight to Number, so a map holding a non-numeric coordinate raised ClassCastException. Callers that deliberately skip a value they cannot parse catch IllegalArgumentException, so the CCE escaped them and failed the whole query instead of the one row. Every geo.* consumer gets the fix, not just the two relation methods. - The cell walks asked for a fresh BytesRef per cell. Retarget one instead: the token becomes a String immediately, so nothing outlives the next call, and this is the ingest path #5478 exists to keep cheap. - Release note: a database written by a 26.8.1-SNAPSHOT build from between the FRONTIER change and this one holds unpruned area cells, which a delete on this build would no longer fully remove. REBUILD INDEX rewrites it. Points are unaffected - pruning never applies to them.
|
Both pre-merge checks done, plus the two nits, in 9d02b02. (1) No-op The one that needed proving is Studio, the only place that builds the clause by concatenation - The only two plain-type occurrences are Incidental find, pre-existing and not from this PR: Studio's (2) (3) Snapshot-upgrade delete symmetry. Correct, and worth stating rather than leaving implied. The release note now carries it explicitly: a database written by a Nit - Nit - Nit - 464 tests green across the geospatial, |
Up to standards ✅🟢 Issues
|
| Metric | Results |
|---|---|
| Complexity | 53 |
🟢 Coverage 91.98% diff coverage · -7.11% coverage variation
Metric Results Coverage variation ✅ -7.11% coverage variation Diff coverage ✅ 91.98% diff coverage Coverage variation details
Coverable lines Covered lines Coverage Common ancestor commit (49206db) 140950 106753 75.74% Head commit (9d02b02) 173063 (+32113) 118777 (+12024) 68.63% (-7.11%) Coverage variation is the difference between the coverage for the head and common ancestor commits of the pull request branch:
<coverage of head commit> - <coverage of common ancestor commit>Diff coverage details
Coverable lines Covered lines Diff coverage Pull request (#5604) 162 149 91.98% Diff coverage is the percentage of lines that are covered by tests out of the coverable lines that the pull request added or modified:
<covered lines added or modified>/<coverable lines added or modified> * 100%
NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.
|
Review of #5600 Geospatial misc Reviewed all 6 commits end-to-end. This is a high-quality, well-motivated PR: three genuinely independent findings, each fixed at the right layer, with the fixes and their reasoning documented in code, in the release note, and in tests. The self-review commits (fractional precision, no-duplicate-token assertion, malformed-parameter handling, point-map CCE, BytesRef reuse) already caught most of what I would have flagged. Below is what stood out. Nothing here is blocking. Strengths worth calling out
Minor / discussion points
Test coverage Thorough and targeted: round-trip identity (incl. lists/maps and free-text-with-geo-keyword cases), real-Shape-still-a-Shape, legacy method WKT acceptance, malformed-parameter-reported-vs-bad-row-skipped, precision/tokenization from SQL and Java API, all rejection paths, delete symmetry for area shapes, and the Lucene-equivalence + coverage-monotonicity properties for pruning. Note: I could not execute the suite in this review environment (Maven invocation required an approval not available here), so the above is based on code reading; the PR reports 3182 engine tests green across the affected packages. Overall: LGTM. The points above are minor/informational - none should block merge. |
|
Thanks. Point 2 was the one with a real question behind it, so I traced it end to end - the answer is no internal path can produce
The only string-built Point 1 (per-row WKT re-parsing) - agreed on all counts, including that it is a net win over the per-read sniff it replaces. Noted for the day it shows up in a profile; no change here. Point 3 ( Point 4 - thanks, that was exactly the intent of the rewrite. |
|
Closing the loop on the verification asked for in the earlier review: 1266 test classes, exit 0. One note on method, since it matters for reading any earlier number I posted: an intermediate run of this same command reported 41 failures, and those were not real. 34 were CI's own The one red check, |
… SQL, fewer cells for area shapes (#5604) * fix(engine) #5600: a STRING property stays a STRING, GEOSPATIAL precision is settable from SQL, area shapes index fewer cells Three independent geospatial findings. 1. BinarySerializer.deserializeValue sniffed the first characters of EVERY string it read and, when they looked like the head of a WKT geometry, returned a spatial4j Shape instead of the String that was written. A property declared STRING did not round-trip - getString() answered spatial4j's Pt(x=..,y=..) form, which is not valid WKT - and free text starting with POINT/POLYGON/... was transformed too. The storage layer no longer changes the declared type of a value. Pre-26.2.1 databases, where shapes were stored as WKT text, keep working: GeoUtils.parseGeometry(), GeoUtils.parseJtsGeometry() and LSMTreeGeoIndex.toShape() all accept WKT, and the two legacy SQL methods isWithin()/intersectsWith(), which used to rely on the sniff, now parse their operands themselves. 2. CREATE INDEX ... GEOSPATIAL METADATA {"precision": 6} was parsed, run and silently ignored: CreateIndexStatement forwarded METADATA only for LSM_VECTOR, FULL_TEXT and LSM_SPARSE_VECTOR. GEOSPATIAL now has a builder of its own, TypeGeoIndexBuilder, in the same shape as the other three, so precision and tokenization are reachable from SQL and from the Java API. An unusable METADATA clause - unknown key, out-of-range precision, invalid tokenization, or METADATA on an index type that has no settings - is now an error instead of being dropped. withType() also records the type on the original builder, so a caller that ignores the returned specialised instance no longer hits "indexType was not specified". 3. The FRONTIER tokenizer collapses a complete set of sibling frontier cells into their parent, recursively - the reduction Lucene calls pruneLeafyBranches and applies by default when indexing. Measured on the frontier cell count: 57% fewer for a small square, 74% for a jagged outline, unchanged for a linestring or a wide rectangle. Points are single-child chains and keep their one-token guarantee on an allocation-free path. Unlike Lucene's implementation, which buffers the whole decomposition in a list, this streams: only the tokens on the current root-to-leaf path can still be revoked, bounding what is held to subCellsSize * detailLevel. Both produce identical token sets, asserted against Lucene directly in LSMTreeGeoIndexCellPruningTest. * #5600 code review: reject a fractional precision, call out the METADATA rejection as breaking - METADATA {"precision": 6.9} passed the instanceof Number check and was truncated to 6. A GeoHash precision is a tree level, so truncating drops exactly the kind of typo this validation exists to report. - The release note now flags the runtime rejection of a METADATA clause on an index type with no settings (CREATE INDEX ... UNIQUE METADATA {...}) as a breaking change in its own right, not only as the geospatial angle: the grammar still accepts such a statement, only running it is refused. * #5600 code review: assert the prune emits no token twice, state what the depth invariant actually guards - The Lucene-equivalence test compares SETS, so a token emitted twice would have been invisible there while production - which collects into a List and issues one put() per token - wrote a duplicate index entry. Assert on the raw stream instead. It passes, so no duplicate exists; the point is that it would now be caught. - The comment on `assert depth == level` implied the algorithm hinges on it. It does not: a frame's parent is the frame below it, found by pop order and not by level arithmetic, and the array bound holds because the pop loop keeps the levels on the stack strictly increasing. The assert documents the GeoHash one-character-per-level contract; say that. * #5600 code review: a malformed WKT literal is reported, the two geo relation methods share a base - isWithin/intersectsWith caught every IllegalArgumentException and answered null, so a typo in the query - coords.isWithin('POLYGONN (...)') - silently read as "no match" and was awkward to debug. The two operands are not the same kind of input: the parameter is written in the query, so a malformed one is a mistake to report, exactly as a missing one already was; the value comes from the record, so a row that holds no geometry is skipped, because failing the whole query over one bad row would be worse. - Both methods carried an identical copy of that parsing block. They now share AbstractSQLGeoRelationMethod and differ only in the SpatialRelation they accept, so the parsing contract has one definition. - TypeGeoIndexBuilder.withMetadata(JSONObject) returns the builder, matching the withMetadata(IndexMetadata) override next to it. * #5600 cover the new public API, drop the constructor nothing calls Codecov flagged the patch: TypeGeoIndexBuilder at 69% and AbstractSQLGeoRelationMethod at 53%, because the Java-API surface the PR adds was only ever reached through SQL. - TypeGeoIndexBuilderTest exercises it directly: withPrecision/withTokenization reach the created index, withMetadata(JSONObject) chains and tolerates null, another index type's metadata is rejected, a setter on non-geo metadata reports the state instead of throwing ClassCastException, and withGeoType() is identity on a geo builder and refuses a non-geospatial one. - relationMethodsHandleTheDegenerateOperands covers the operand shapes the two relation methods had left untested: a missing or null parameter is a query mistake, an empty one has no geometry to relate and simply does not match. - The TypeGeoIndexBuilder(database, typeName, propertyNames) constructor had no caller. It was mirrored from TypeFullTextIndexBuilder, where it is equally unused; no reason to add dead code to a new class. * #5600 code review: a bad point map skips its row, reuse the cell BytesRef, flag the snapshot rebuild - GeoUtils.parseGeometry cast a point map's x/y straight to Number, so a map holding a non-numeric coordinate raised ClassCastException. Callers that deliberately skip a value they cannot parse catch IllegalArgumentException, so the CCE escaped them and failed the whole query instead of the one row. Every geo.* consumer gets the fix, not just the two relation methods. - The cell walks asked for a fresh BytesRef per cell. Retarget one instead: the token becomes a String immediately, so nothing outlives the next call, and this is the ingest path #5478 exists to keep cheap. - Release note: a database written by a 26.8.1-SNAPSHOT build from between the FRONTIER change and this one holds unpruned area cells, which a delete on this build would no longer fully remove. REBUILD INDEX rewrites it. Points are unaffected - pruning never applies to them. (cherry picked from commit 4a026c0)
Closes #5600. The three findings are independent; each is a separate commit-sized change kept together because they all touch the geospatial path.
1. A
STRINGproperty no longer reads back as aShapeBinarySerializer.deserializeValuesniffed the first characters of every string it read and, when they matchedPOINT/CIRCLE/LINESTRING/POLYGON/ENVELOPE/BUFFER, parsed the value as WKT and returned a spatial4jShape. A property declaredSTRINGtherefore did not round-trip, and the trigger was a prefix match on arbitrary user text rather than a schema decision.The sniff is gone: a string reads back as the string that was written, and the
startsWithchain no longer runs on every string read in the database.Backward compatibility is kept where the conversion actually belongs - at the point a geometry is asked for.
GeoUtils.parseGeometry(),GeoUtils.parseJtsGeometry()andLSMTreeGeoIndex.toShape()already accepted WKT text, so a pre-26.2.1 database (shapes stored as WKT underTYPE_STRING) keeps working, and so doesgeo.point(), which returns WKT.Two callers did rely on the sniff and are fixed at the right layer:
SQLMethodIsWithinandSQLMethodIntersectsWithreturnednullunless the value was already aShape. They now parse their operands throughGeoUtils.parseGeometry, like everygeo.*function, socoords.isWithin(...)works on a WKT column and accepts a WKT literal as its parameter too.Better alternative suggested by the issue and not taken here: an explicit
Type.GEOMETRYin the schema would be the cleanest long-term answer, but that is a schema-format change with its own migration story - worth a separate issue rather than folding into this one.2.
GEOSPATIALprecisionis settable from SQLparsed, ran, and silently built the index at the default precision of 11.
The issue proposed mirroring the three existing
METADATAbranches. The better fix is one level up:withType()already returns a specialised builder that owns its metadata forLSM_VECTOR,FULL_TEXTandLSM_SPARSE_VECTOR;GEOSPATIALwas simply missing from that list, which is why its metadata had nowhere to go. It now hasTypeGeoIndexBuilder, in the same shape as the others, soprecisionandtokenizationare reachable from SQL and from the Java API (withPrecision,withTokenization) alike.Two related sharp edges went with it:
METADATAis rejected, not dropped. An unknown key, a non-numeric or out-of-range precision, an invalid tokenization, or aMETADATAclause on an index type that has no settings at all now raiseCommandSQLParsingException. Silently ignoring the clause is what kept this gap invisible. Parsing is unchanged, so the existing parser tests still hold.withType()no longer leaves the original builder unconfigured. It returns a new object, sobuilder.withType(X); builder.create();- which the pre-existingLSMTreeGeoIndexSchemaTestdoes - failed withindexType was not specifiedonce GEOSPATIAL got a subclass. The type is now recorded on the original builder as well, which also removes the same latent trap for full-text and vector callers.Note the builder parses the
METADATAkeys itself rather than delegating toGeoIndexMetadata.fromJSON(): that method reads a persisted definition, where a missingtokenizationmeans a pre-26.8.1 index and therefore theFULLlayout. In aCREATE INDEXa missing key just means the user did not ask, so the creation-time default has to stand.3. An area shape indexes fewer cells
The issue asked to measure before adopting Lucene's
pruneLeafyBranches. Measured, on the frontier cell count:Worth taking. A complete set of sibling frontier cells now collapses into its parent, recursively. A parent covers the union of its children, so the cover can only grow and a match is never lost; the
geo.*predicate post-filters the superset either way.Better alternative than the issue's proposal. The issue suggested subclassing the strategy to expose
createCellIteratorToIndex. That implementation materialises the entire decomposition in anArrayList(pre-sized to 4096), which is exactly the caveat the issue flagged from Lucene's own javadoc. This implements the prune as a streaming pass over the existing walk instead: only the frontier tokens on the current root-to-leaf path can still be revoked by an ancestor collapsing, so what is held is bounded bysubCellsSize * detailLevelregardless of shape size. Points - the hot ingest path of #5478 - take an allocation-free shortcut, since a chain of single-child cells can never hold a complete sibling set.LSMTreeGeoIndexCellPruningTestasserts the streaming output is identical to Lucene's buffered one, invokingcreateCellIteratorToIndexby reflection, over 8 shapes x 4 precisions, plus a coverage-never-shrinks property and the one-token-per-point guarantee.This changes what a new index writes for area shapes. Indexes in the
FULLlayout are untouched, andFRONTIERhas not shipped in any release (#5478 merged into 26.8.1-SNAPSHOT today), so no published database holds the unpruned form.Tests
New:
Issue5600StringWktRoundTripTest(7),LSMTreeGeoIndexCellPruningTest(3). Extended:LSMTreeGeoIndexSchemaTest(+9),LSMTreeGeoIndexTokenizationTest(+2, polygon delete symmetry and a pruned jagged polygon at every query resolution).Regression: 3182 engine tests in
serializer,query.sql,schema,index.geospatial,index.fulltextand the LSM index suites - 0 failures.