Skip to content

fix(massif): add && bbox pre-filter to massif spatial joins - #1819

Merged
ClemRz merged 4 commits into
developfrom
fix/1811-massif-spatial-join-bbox-prefilter
Sep 28, 2026
Merged

ClemRz merged 4 commits into
developfrom
fix/1811-massif-spatial-join-bbox-prefilter

Conversation

@ClemRz

@ClemRz ClemRz commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

🤔 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.

  • Fixes the two queries named in perf(api): missing && bbox pre-filter on massif spatial joins costs ~39h of DB time #1811 — EntranceService (per-entrance) and dbSync/entities/entrance (batched)
  • Fixes three more sites with the same defect that the issue didn't name — CaveService.getMassifs, and the two correlated counts in GeoLocService.MASSIFS_IN_BOUNDS
  • Fixes MassifService.FIND_CAVES_IN_MASSIF and FIND_NETWORKS_IN_MASSIF
  • Adds MassifService.findMassifsByEntranceIds() as the single copy of the batched spatial join, shared with the dbSync export
  • Aligns the superseded copy of the v_region_info definition, the last spatial join in sql/ without a pre-filter
  • Moves the five massif-fixed joins that predate this branch onto the ::geometry form as well, so every site that fixes the massif expresses the pre-filter identically
  • Enforces the invariant the pre-filter depends on, by rejecting massif polygons that straddle the 180° meridian
  • Closes perf(api): missing && bbox pre-filter on massif spatial joins costs ~39h of DB time #1811

🤷‍♂️ Why

ST_Contains is a function call, not an indexable operator. Without an && pre-filter PostGIS cannot use the GiST index on t_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 in MassifService, four in 91_materialized_views.sql), so this is a consistency fix rather than a new technique.

🔍 How

Two operand forms are required, because point_geom is geometry(Point,4326) while geog_polygon is geography, and the operand types decide which index is reachable:

Case Form Index reached
Entrance fixed, massifs scanned e.point_geom && m.geog_polygon idx_t_massif_geog (geography) via the implicit geometry→geography cast
Massif fixed by id, entrances scanned e.point_geom && m.geog_polygon::geometry idx_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_Contains carries a PostGIS index support function, which rewrites it into an e.point_geom @ m.geog_polygon::geometry index condition by itself — so at the massif-fixed sites idx_entrance_geom_gist was 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, and t_massif has only a geography one. Without the pre-filter those plans degrade to a full scan of t_massif with st_contains as a join filter — which is the 39 hours, and the 263–498× below.

So the ::geometry cast at the massif-fixed sites buys equivalence, not a plan change. Its box is exactly the box of the ST_Contains argument beside it, so those joins need no antimeridian reasoning at all, and the geography form's per-row (e.point_geom)::geography cast drops out of the Filter. 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_public is unusable by those queries — its predicate requires is_sensitive = false, which they don't guarantee — but the unconditional idx_entrance_geom_gist covers them.

FIND_NETWORKS_IN_MASSIF needed 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, and m.id = $1 is 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:

  • GeoLocService lines 60 and 80 — already narrowed by ST_Within(..., ST_MakeEnvelope(...)), so the candidate set is tiny
  • The CSV import fan-out — threading resolved massifs through createEntrance is invasive, and each call is now ~0.1 ms against a path that already issues many queries per row

Enforcing the antimeridian invariant

The pre-filter is equivalent to a bare ST_Contains only 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:

entrance ST_Contains &&
longitude 180, inside the drawn box false true
longitude 0, 20,000 km away true false

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.validatePolygon now rejects both offending shapes with POLYGON_CROSSES_ANTIMERIDIAN:

  • a longitude outside [-180, 180], the likely one — 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 (NOTICE: 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°

The existing checks miss 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 existing 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 POLYGON_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 in 91_materialized_views.sql. The massif-fixed joins stopped depending on it once they all carried ::geometry.

The superseded v_region_info definition

Two definitions of this materialized view live in sql/:

File Join Indexable
2_2025_11_07_region_info_view.sql ST_Contains(..., ST_MakePoint(e.longitude, e.latitude)) No — builds the point inline, so neither GiST index is reachable
91_materialized_views.sql e.point_geom && m.geog_polygon AND ST_Contains(...) Yes

The first is dead code. 91_ drops and recreates the view and sorts after 2_ in the byte order the postgres initdb entrypoint uses, over the sql/ directory docker/docker-compose.yml:18 mounts as docker-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 naming 91_ as authoritative.

dbSync/entities/entrance.js requires MassifService inside the function rather than at module scope: SearchService requires that module at load time to read its search schema, so a top-level require returns a partial module. Same pattern and comment style as NotificationService lines 275 and 632.

🧪 Testing

EXPLAIN (ANALYZE, BUFFERS) on production, per call:

entrance before after speedup buffers
74132 (in a massif) 47.67 ms 0.18 ms 263× 32,407 → 14
137222 (in no massif — the hot case) 45.79 ms 0.09 ms 498× 32,401 → 5

The new plan confirms the mechanism:

Index Scan using idx_t_massif_geog on t_massif m
      Index Cond: (geog_polygon && (e.point_geom)::geography)
      Filter: st_contains((geog_polygon)::geometry, e.point_geom)

ST_Contains is now a cheap recheck on 1–2 candidates. There is also no Filter: (NOT is_deleted) on t_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_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 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:

SELECT id FROM t_massif
WHERE is_deleted = false AND geog_polygon IS NOT NULL
  AND (ST_XMax(geog_polygon::geometry) - ST_XMin(geog_polygon::geometry) > 180
       OR ST_XMin(geog_polygon::geometry) < -180
       OR ST_XMax(geog_polygon::geometry) > 180);

0 rows, so the pre-filter cannot exclude a row that ST_Contains would have matched. The new validatePolygon guard 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 develop baseline 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 in Changes/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.

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.
@ClemRz
ClemRz force-pushed the fix/1811-massif-spatial-join-bbox-prefilter branch from c3a8afb to acfebb3 Compare September 24, 2026 21:52
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.
@ClemRz
ClemRz requested a review from Paul-AUB September 24, 2026 22:08
@ClemRz ClemRz self-assigned this Sep 24, 2026
Paul-AUB
Paul-AUB previously approved these changes Sep 24, 2026

@Paul-AUB Paul-AUB left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestions (Should Consider)

  1. [api/services/MassifService.js:120] The three COUNT_*_IN_MASSIF queries (lines 120, 129, and 142), as well as propagateSensitivityToEntrances around line 485, still use e.point_geom && m.geog_polygon even though the massif is fixed by ID. PostgreSQL resolves that mixed-type operator to geography and casts e.point_geom, so idx_entrance_geom_gist cannot assist the entrance scan. Consider using e.point_geom && m.geog_polygon::geometry, as the updated FIND_CAVES_IN_MASSIF and FIND_NETWORKS_IN_MASSIF queries 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.
@ClemRz

ClemRz commented Sep 28, 2026

Copy link
Copy Markdown
Contributor Author

Applied in de1c04ca, though not for the reason given — idx_entrance_geom_gist was already being reached at those sites, and the PR description is what said otherwise. I've corrected it.

ST_Contains carries a PostGIS index support function, so it generates its own index condition with no && present at all:

COUNT_ENTRANCES_IN_MASSIF with the pre-filter stripped entirely:

  ->  Index Scan using idx_entrance_geom_gist on t_entrance e  (cost=0.14..20.66 rows=1)
        Index Cond: (e.point_geom @ (m.geog_polygon)::geometry)
        Filter: ((NOT e.is_deleted) AND st_contains((m.geog_polygon)::geometry, e.point_geom))

Same index and same cost to two decimal places across all three variants — no pre-filter, the geography form, and ::geometry. The only thing that moves is where the pre-filter lands:

form placement
&& m.geog_polygon Filter: ((e.point_geom)::geography && m.geog_polygon), re-tested per candidate row
&& m.geog_polygon::geometry folded into Index Cond beside the @, redundant with it

It fires through a scalar subquery too, which makes the FIND_NETWORKS_IN_MASSIF restructure in this PR readability only rather than a plan win:

  ->  Index Scan using idx_entrance_geom_gist on t_entrance e
        Index Cond: (e.point_geom @ $0)

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 — t_massif has only idx_t_massif_geog:

variant massif side
no && full idx_t_massif_geog scan, no index cond, st_contains as a join filter, rows=6
&& Index Cond: (m.geog_polygon && (e.point_geom)::geography), rows=1

That asymmetry is the 39 hours, and it's why the geography form has to stay at those sites.

So the ::geometry change is worth making for a different reason than performance: its box is exactly the box of the ST_Contains argument beside it, so those joins stop depending on the antimeridian invariant entirely, and the per-row geography cast drops out of the filter. Cheap and strictly safer.

Five sites rather than four — dbSync/entities/massif.js:20 has the same shape and wasn't in the list. FIND_MASSIFS_BY_ENTRANCE_IDS keeps the geography form (entrance ids are the fixed side) and so does isPointInSensitiveMassif (point literal, massifs scanned).

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.

@Paul-AUB Paul-AUB left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the current changes against #1811 and the existing PR discussion. I found no blocking issues.

@ClemRz
ClemRz merged commit 432d014 into develop Sep 28, 2026
5 checks passed
@ClemRz
ClemRz deleted the fix/1811-massif-spatial-join-bbox-prefilter branch September 28, 2026 22:13

This branch was successfully deployed

1 active deployment
build — de1c04ca Deployed Sep 28, 2026 by ClemRz via build-test #3875
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf(api): missing && bbox pre-filter on massif spatial joins costs ~39h of DB time

2 participants