fix(massif): add && bbox pre-filter to massif spatial joins - #1819
Conversation
The spatial joins resolving which massif contains an entrance called ST_Contains with no && bounding-box pre-filter. ST_Contains is a function call, not an indexable operator, so PostGIS could not use the GiST index on t_massif(geog_polygon) and evaluated exact point-in-polygon against every massif polygon. On production the per-entrance variant was the second-largest consumer of database time: 39.3 hours cumulative over 1,222,480 calls (115.9 ms mean) with avg_rows = 0, meaning most of that work produced no rows at all. EXPLAIN (ANALYZE, BUFFERS) measured on production, per call: entrance in a massif 47.67 ms -> 0.18 ms 32,407 -> 14 buffers entrance in no massif 45.79 ms -> 0.09 ms 32,401 -> 5 buffers The old plan compounded two problems: ~6,400 exact ST_Contains evaluations per call, and t_massif on the outer side of the nested loop, which re-scanned t_entrance by primary key 6,401 times to re-read the same row (25,604 of those buffers). It also launched parallel workers on every call. Two operand forms are needed, because point_geom is geometry while geog_polygon is geography: - entrance fixed, massifs scanned: e.point_geom && m.geog_polygon, so the implicit geometry->geography cast makes idx_t_massif_geog reachable - massif fixed by id, entrances scanned: e.point_geom && m.geog_polygon::geometry, since the t_entrance GiST index is geometry FIND_NETWORKS_IN_MASSIF needed the massif lifted out of a scalar subquery into a join to express the pre-filter. Its behaviour is unchanged: a missing massif or null polygon still yields no rows, and m.id = $1 is a primary key match so there is no row fan-out. Verified on production that no massif polygon crosses the antimeridian, so each geodetic bounding box is a superset of its planar one and the pre-filter cannot exclude a row that ST_Contains would have matched. Also batches the massif lookup for the two search-reindex fan-outs. MassifService.findMassifsByEntranceIds() now owns the batched join as the single copy of that SQL, shared with the dbSync export, and updateInSearch() accepts already-resolved massifs so massif/create and massif/mark-sensitive issue one query instead of one per entrance. The CSV import is left per-entrance deliberately: each call is now ~0.1 ms against a path that already issues many queries per row. Closes #1811
sql/2_2025_11_07_region_info_view.sql joins on ST_Contains with the point built inline by ST_MakePoint(e.longitude, e.latitude), so neither the GiST index on t_massif(geog_polygon) nor the one on t_entrance(point_geom) can be used. That definition is dead. 91_materialized_views.sql drops and recreates the view with the correct e.point_geom && m.geog_polygon form, and sorts after this file in the byte order the postgres initdb entrypoint uses, over the sql/ directory docker-compose mounts as docker-entrypoint-initdb.d. Verified on production: all four materialized views carry the pre-filter, so no migration is needed. Align the join anyway, because this is the one place left in sql/ that models a spatial join without a pre-filter, and add a comment pointing at 91_ as the authoritative definition so the file is not read as a model.
c3a8afb to
acfebb3
Compare
The && bounding-box pre-filter is equivalent to a bare ST_Contains only while a massif polygon stays on one side of the 180° meridian. The pre-filter compares geographies, so its box follows great-circle edges; ST_Contains compares geometries, so its box is planar. For a polygon that straddles the antimeridian the two invert, measured on a box off Fiji drawn at longitude 179.8 to 180.2: entrance at longitude 180 (inside the drawn box) ST_Contains f && t entrance at longitude 0 (20,000 km away) ST_Contains t && f So the join returns nothing where it used to return one wrong row. That was already broken before the pre-filter existed, but the invariant the pre-filter relies on was only ever verified against the corpus as it stood, never enforced, and massif polygons are user-drawn. Two shapes reach validatePolygon. The likely one is a longitude outside [-180, 180]: the map reports coordinates from a repeated world copy when the user pans past the edge, and the geometry -> geography cast on write wraps them (PostGIS: "Coordinate values were coerced into range"), turning a small box into one spanning nearly 360°. A polygon already in range but spanning more than 180° is the other. Both are rejected with POLYGON_CROSSES_ANTIMERIDIAN. The existing checks do not cover this. The Fiji box is 1879 km², well inside the 35000 km² cap, and valid in 2D, so ST_IsValid and ST_Area on geography both pass. The bounds ride along in the ST_IsValidDetail round trip rather than adding one, since they are geometry arithmetic and cannot raise on a geometry that is not computable as a geography. The span test is > 180 rather than >= 180, which leaves the geoJsonAntipodalEdge fixture spanning exactly 180° to the ST_Area XX000 path that already reports it as POLYGON_ANTIPODAL_EDGE. This also protects the six spatial joins that carried the pre-filter before this branch, in dbSync/entities/massif.js, the three COUNT_*_IN_MASSIF queries, isPointInSensitiveMassif and propagateSensitivityToEntrances, plus the four materialized views in 91_materialized_views.sql. Tests cover both rejected shapes at the service level and through the create and update routes, plus the invariant itself: that the geodetic bounding box covers the planar one for an accepted polygon and fails to for a rejected one.
Paul-AUB
left a comment
There was a problem hiding this comment.
Suggestions (Should Consider)
- [api/services/MassifService.js:120] The three
COUNT_*_IN_MASSIFqueries (lines 120, 129, and 142), as well aspropagateSensitivityToEntrancesaround line 485, still usee.point_geom && m.geog_polygoneven though the massif is fixed by ID. PostgreSQL resolves that mixed-type operator to geography and castse.point_geom, soidx_entrance_geom_gistcannot assist the entrance scan. Consider usinge.point_geom && m.geog_polygon::geometry, as the updatedFIND_CAVES_IN_MASSIFandFIND_NETWORKS_IN_MASSIFqueries now do. This is not a regression in this PR, but it leaves several related massif-fixed paths scanning all entrances despite the operand-form distinction documented in the PR.
The three COUNT_*_IN_MASSIF queries, propagateSensitivityToEntrances and the dbSync massif export all fix the massif by id, so their pre-filter belongs in the ::geometry form used by the other massif-fixed joins. Review read the geography form there as costing an index scan on t_entrance, which it was not. ST_Contains carries a PostGIS index support function that rewrites it into an e.point_geom @ m.geog_polygon::geometry index condition by itself, so idx_entrance_geom_gist was reached either way. What the cast buys is equivalence - its box is exactly the box of the ST_Contains argument beside it, so these joins stop depending on the antimeridian invariant - plus one fewer per-row (e.point_geom)::geography cast in the filter. Verified plan-neutral on PostGIS 3.4.3 and PostgreSQL 16, with the same index, the same index condition and cost equal to two decimal places. Suite unchanged at 3478 passing, 4 failing, the same pre-existing #1772 failures.
|
Applied in
Same index and same cost to two decimal places across all three variants — no pre-filter, the geography form, and
It fires through a scalar subquery too, which makes the What the rewrite cannot do is rescue the entrance-fixed sites, because the condition it produces sits on the massif side and needs a geometry index there —
That asymmetry is the 39 hours, and it's why the geography form has to stay at those sites. So the Five sites rather than four — Plans above are from PostGIS 3.4.3 / PostgreSQL 16 locally; production is 3.6.1 / 16.15, so the support function is present on both (it landed in 3.0). Suite unchanged at 3478 passing, 4 failing — the same pre-existing #1772 failures. |
🤔 What
Adds the missing
&&bounding-box pre-filter to every massif↔entrance spatial join, and batches the massif lookup for the two search-reindex fan-outs.EntranceService(per-entrance) anddbSync/entities/entrance(batched)CaveService.getMassifs, and the two correlated counts inGeoLocService.MASSIFS_IN_BOUNDSMassifService.FIND_CAVES_IN_MASSIFandFIND_NETWORKS_IN_MASSIFMassifService.findMassifsByEntranceIds()as the single copy of the batched spatial join, shared with the dbSync exportv_region_infodefinition, the last spatial join insql/without a pre-filter::geometryform as well, so every site that fixes the massif expresses the pre-filter identically🤷♂️ Why
ST_Containsis a function call, not an indexable operator. Without an&&pre-filter PostGIS cannot use the GiST index ont_massif(geog_polygon), so it evaluated exact point-in-polygon against every massif polygon.On production the per-entrance variant was the second-largest consumer of database time on the server: 39.3 hours cumulative over 1,222,480 calls at 115.9 ms mean, with
avg_rows = 0— most of that work produced no rows at all.The pattern was already correct in six other places (
dbSync/entities/massif.js, three queries inMassifService, four in91_materialized_views.sql), so this is a consistency fix rather than a new technique.🔍 How
Two operand forms are required, because
point_geomisgeometry(Point,4326)whilegeog_polygonisgeography, and the operand types decide which index is reachable:e.point_geom && m.geog_polygonidx_t_massif_geog(geography) via the implicitgeometry→geographycaste.point_geom && m.geog_polygon::geometryidx_entrance_geom_gist(geometry)Only the first row is where an index is won or lost, and an earlier draft of this description got that wrong.
ST_Containscarries a PostGIS index support function, which rewrites it into ane.point_geom @ m.geog_polygon::geometryindex condition by itself — so at the massif-fixed sitesidx_entrance_geom_gistwas already reached with no pre-filter at all, even when the polygon arrives as a scalar subquery (Index Cond: (e.point_geom @ $0)). That rewrite cannot rescue the entrance-fixed sites, because the condition it produces sits on the massif side and needs a geometry index there, andt_massifhas only a geography one. Without the pre-filter those plans degrade to a full scan oft_massifwithst_containsas a join filter — which is the 39 hours, and the 263–498× below.So the
::geometrycast at the massif-fixed sites buys equivalence, not a plan change. Its box is exactly the box of theST_Containsargument beside it, so those joins need no antimeridian reasoning at all, and the geography form's per-row(e.point_geom)::geographycast drops out of theFilter. Verified plan-neutral on PostGIS 3.4.3 / PostgreSQL 16: same index, same index condition, cost equal to two decimal places. Production runs PostGIS 3.6.1 on PostgreSQL 16.15, so the support function is present there too — it landed in PostGIS 3.0.Note
idx_t_entrance_geom_publicis unusable by those queries — its predicate requiresis_sensitive = false, which they don't guarantee — but the unconditionalidx_entrance_geom_gistcovers them.FIND_NETWORKS_IN_MASSIFneeded the massif lifted out of a scalar subquery into a join to express the pre-filter. Behaviour is unchanged: a missing massif or null polygon still yields no rows, andm.id = $1is a primary key match so there is no row fan-out. This one is readability only — the scalar-subquery form reached the index too.Deliberately left alone:
GeoLocServicelines 60 and 80 — already narrowed byST_Within(..., ST_MakeEnvelope(...)), so the candidate set is tinycreateEntranceis invasive, and each call is now ~0.1 ms against a path that already issues many queries per rowEnforcing the antimeridian invariant
The pre-filter is equivalent to a bare
ST_Containsonly while a polygon stays on one side of the 180° meridian — the pre-filter's box follows great-circle edges,ST_Contains' box is planar. For a polygon that straddles it the two invert, measured on a box off Fiji drawn at longitude 179.8 → 180.2:ST_Contains&&So such a join returns nothing where it previously returned one wrong row. Already broken before the pre-filter, but the invariant was only ever verified against the corpus as it stood, never enforced — and massif polygons are user-drawn.
MassifService.validatePolygonnow rejects both offending shapes withPOLYGON_CROSSES_ANTIMERIDIAN:[-180, 180], the likely one — the map reports coordinates from a repeated world copy when the user pans past the edge, and thegeometry → geographycast on write wraps them (NOTICE: Coordinate values were coerced into range), turning a small box into one spanning nearly 360°The existing checks miss this: the Fiji box is 1879 km², well inside the 35000 km² cap, and valid in 2D, so
ST_IsValidandST_Areaon geography both pass. The bounds ride along in the existingST_IsValidDetailround trip rather than adding one, since they are geometry arithmetic and cannot raise on a geometry that is not computable as a geography. The span test is> 180rather than>= 180, which leaves thegeoJsonAntipodalEdgefixture spanning exactly 180° to theST_AreaXX000 path that already reportsPOLYGON_ANTIPODAL_EDGE.The invariant is only load-bearing where the pre-filter is a geography box: the three entrance-fixed joins above,
isPointInSensitiveMassif, and the four materialized views in91_materialized_views.sql. The massif-fixed joins stopped depending on it once they all carried::geometry.The superseded
v_region_infodefinitionTwo definitions of this materialized view live in
sql/:2_2025_11_07_region_info_view.sqlST_Contains(..., ST_MakePoint(e.longitude, e.latitude))91_materialized_views.sqle.point_geom && m.geog_polygon AND ST_Contains(...)The first is dead code.
91_drops and recreates the view and sorts after2_in the byte order the postgres initdb entrypoint uses, over thesql/directorydocker/docker-compose.yml:18mounts asdocker-entrypoint-initdb.d. Confirmed on production — all four materialized views carry the pre-filter — so no migration is needed, and none is included here.The join is aligned anyway, since this was the one place left in
sql/modelling a spatial join without a pre-filter, with a comment naming91_as authoritative.dbSync/entities/entrance.jsrequiresMassifServiceinside the function rather than at module scope:SearchServicerequires that module at load time to read its search schema, so a top-level require returns a partial module. Same pattern and comment style asNotificationServicelines 275 and 632.🧪 Testing
EXPLAIN (ANALYZE, BUFFERS)on production, per call:The new plan confirms the mechanism:
ST_Containsis now a cheap recheck on 1–2 candidates. There is also noFilter: (NOT is_deleted)ont_massif, so the planner proved the partial index predicate subsumes it.The old plan was worse than the issue characterised, for two compounding reasons: ~6,400 exact
ST_Containsevaluations per call, andt_massifon the outer side of the nested loop, which re-scannedt_entranceby primary key 6,401 times to re-read the same single row — 25,604 of those 32,401 buffers. It also launched parallel workers on every one of the 1.22M calls; the new plan is a plain nested loop, freeing those worker slots.Result-equality. The geography form swaps a planar bounding box for a geodetic one, which is only a guaranteed superset if no polygon crosses the antimeridian. Verified on production:
0 rows, so the pre-filter cannot exclude a row that
ST_Containswould have matched. The newvalidatePolygonguard is what keeps that true for data written from here on, rather than leaving it a property of the corpus at one moment.Suite. 3478 passing, 4 failing. The
developbaseline with these changes stashed is 3472 passing, 4 failing — so the delta is exactly the 6 tests added here: both rejected shapes at the service level, the create and update routes, and the invariant itself (the geodetic bounding box covers the planar one for an accepted polygon and fails to for a rejected one). The 4 failures are pre-existing inChanges/get-recent-comment-relevance-swap.test.js(#1772) and untouched here. Shard 1 (Entrances/Caves, 275) and Shard 4 (Massifs/Search, 282) — the shards covering these queries — pass fully. ESLint and Prettier clean.📸 Previews
N/A — no user-facing change.