From 22f3c44dc2e4eab40d029b3eda143621cabbaed4 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 8 Jul 2026 06:30:32 +0000 Subject: [PATCH] fix(site_finder): SAVEPOINT-isolate quarter-dump lookup db.execute (#2464 Wave 2) (#2467) --- .../site_finder/quarter_dump_lookup.py | 631 +++++++++--------- .../test_quarter_dump_savepoint.py | 224 +++++++ 2 files changed, 545 insertions(+), 310 deletions(-) create mode 100644 backend/tests/services/site_finder/test_quarter_dump_savepoint.py diff --git a/backend/app/services/site_finder/quarter_dump_lookup.py b/backend/app/services/site_finder/quarter_dump_lookup.py index 89a33bfe..bb853221 100644 --- a/backend/app/services/site_finder/quarter_dump_lookup.py +++ b/backend/app/services/site_finder/quarter_dump_lookup.py @@ -355,31 +355,32 @@ def _get_zoning( if layer_counts is not None and layer_counts.get("territorial_zones_count", 1) == 0: return None try: - row = db.execute( - text( - """ - SELECT feat.value->'properties' AS zone_props - FROM nspd_quarter_dumps d, - jsonb_array_elements(d.features_json) AS feat(value) - WHERE d.quarter_cad = :q - AND feat.value->>'layer' = 'territorial_zones' - AND (feat.value->'geometry') IS NOT NULL - AND feat.value->>'geometry' != 'null' - AND ST_Intersects( - ST_Transform( - ST_SetSRID( - ST_GeomFromGeoJSON(feat.value->>'geometry'), - 3857 + with db.begin_nested(): + row = db.execute( + text( + """ + SELECT feat.value->'properties' AS zone_props + FROM nspd_quarter_dumps d, + jsonb_array_elements(d.features_json) AS feat(value) + WHERE d.quarter_cad = :q + AND feat.value->>'layer' = 'territorial_zones' + AND (feat.value->'geometry') IS NOT NULL + AND feat.value->>'geometry' != 'null' + AND ST_Intersects( + ST_Transform( + ST_SetSRID( + ST_GeomFromGeoJSON(feat.value->>'geometry'), + 3857 + ), + 4326 ), - 4326 - ), - ST_Centroid(ST_GeomFromText(:wkt, 4326)) - ) - LIMIT 1 - """ - ), - {"q": quarter, "wkt": parcel_wkt}, - ).first() + ST_Centroid(ST_GeomFromText(:wkt, 4326)) + ) + LIMIT 1 + """ + ), + {"q": quarter, "wkt": parcel_wkt}, + ).first() if row is None: return None @@ -453,45 +454,46 @@ def _get_zouit_overlaps( # geography для корректных м². Делитель — площадь участка; guard от деления на # ноль через NULLIF(...,0) → NULL → coverage_pct None в Python. Zone-4326 # выражение совпадает с ST_Intersects ниже (transform 3857→4326). - rows = db.execute( - text( - """ - SELECT feat.value->>'layer' AS layer, - feat.value->'properties' AS props, - ST_Area( - ST_Intersection( - ST_Transform( - ST_SetSRID( - ST_GeomFromGeoJSON(feat.value->>'geometry'), - 3857 + with db.begin_nested(): + rows = db.execute( + text( + """ + SELECT feat.value->>'layer' AS layer, + feat.value->'properties' AS props, + ST_Area( + ST_Intersection( + ST_Transform( + ST_SetSRID( + ST_GeomFromGeoJSON(feat.value->>'geometry'), + 3857 + ), + 4326 ), - 4326 + ST_GeomFromText(:wkt, 4326) + )::geography + ) / NULLIF( + ST_Area(ST_GeomFromText(:wkt, 4326)::geography), 0 + ) AS coverage_pct + FROM nspd_quarter_dumps d, + jsonb_array_elements(d.features_json) AS feat(value) + WHERE d.quarter_cad = :q + AND feat.value->>'layer' LIKE 'zouit_%' + AND (feat.value->'geometry') IS NOT NULL + AND feat.value->>'geometry' != 'null' + AND ST_Intersects( + ST_Transform( + ST_SetSRID( + ST_GeomFromGeoJSON(feat.value->>'geometry'), + 3857 ), - ST_GeomFromText(:wkt, 4326) - )::geography - ) / NULLIF( - ST_Area(ST_GeomFromText(:wkt, 4326)::geography), 0 - ) AS coverage_pct - FROM nspd_quarter_dumps d, - jsonb_array_elements(d.features_json) AS feat(value) - WHERE d.quarter_cad = :q - AND feat.value->>'layer' LIKE 'zouit_%' - AND (feat.value->'geometry') IS NOT NULL - AND feat.value->>'geometry' != 'null' - AND ST_Intersects( - ST_Transform( - ST_SetSRID( - ST_GeomFromGeoJSON(feat.value->>'geometry'), - 3857 + 4326 ), - 4326 - ), - ST_GeomFromText(:wkt, 4326) - ) - """ - ), - {"q": quarter, "wkt": parcel_wkt}, - ).fetchall() + ST_GeomFromText(:wkt, 4326) + ) + """ + ), + {"q": quarter, "wkt": parcel_wkt}, + ).fetchall() result: list[dict[str, Any]] = [] for r in rows: @@ -547,31 +549,32 @@ def _get_cad_zouit_overlaps(db: Session, parcel_wkt: str) -> list[dict[str, Any] # схлопывает в одну, оставляя наименьший id (ORDER BY reg_numb_border, id). # reg_numb_border — TEXT NOT NULL (0 NULL на проде), так что рег-номер есть у # каждой строки и дедуп идёт по реальному идентификатору зоны. - rows = db.execute( - text( - """ - SELECT DISTINCT ON (reg_numb_border) - type_zone, - category_name, - name_by_doc AS name, - reg_numb_border, - id AS zouit_id, - ST_AsGeoJSON(CAST(geom AS geometry)) AS geom_geojson, - ST_Area( - ST_Intersection( - CAST(geom AS geometry), - ST_GeomFromText(:wkt, 4326) - )::geography - ) / NULLIF( - ST_Area(ST_GeomFromText(:wkt, 4326)::geography), 0 - ) AS coverage_pct - FROM cad_zouit - WHERE ST_Intersects(geom, ST_GeomFromText(:wkt, 4326)) - ORDER BY reg_numb_border, id - """ - ), - {"wkt": parcel_wkt}, - ).fetchall() + with db.begin_nested(): + rows = db.execute( + text( + """ + SELECT DISTINCT ON (reg_numb_border) + type_zone, + category_name, + name_by_doc AS name, + reg_numb_border, + id AS zouit_id, + ST_AsGeoJSON(CAST(geom AS geometry)) AS geom_geojson, + ST_Area( + ST_Intersection( + CAST(geom AS geometry), + ST_GeomFromText(:wkt, 4326) + )::geography + ) / NULLIF( + ST_Area(ST_GeomFromText(:wkt, 4326)::geography), 0 + ) AS coverage_pct + FROM cad_zouit + WHERE ST_Intersects(geom, ST_GeomFromText(:wkt, 4326)) + ORDER BY reg_numb_border, id + """ + ), + {"wkt": parcel_wkt}, + ).fetchall() result: list[dict[str, Any]] = [] for r in rows: @@ -634,11 +637,28 @@ def _get_engineering_nearby( if layer_counts is not None and layer_counts.get("engineering_count", 1) == 0: return [] try: - rows = db.execute( - text( - """ - SELECT feat.value->'properties' AS props, - ST_Distance( + with db.begin_nested(): + rows = db.execute( + text( + """ + SELECT feat.value->'properties' AS props, + ST_Distance( + ST_Transform( + ST_SetSRID( + ST_GeomFromGeoJSON(feat.value->>'geometry'), + 3857 + ), + 4326 + )::geography, + ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography + ) AS distance_m + FROM nspd_quarter_dumps d, + jsonb_array_elements(d.features_json) AS feat(value) + WHERE d.quarter_cad = :q + AND feat.value->>'layer' = 'engineering_structures' + AND (feat.value->'geometry') IS NOT NULL + AND feat.value->>'geometry' != 'null' + AND ST_DWithin( ST_Transform( ST_SetSRID( ST_GeomFromGeoJSON(feat.value->>'geometry'), @@ -646,31 +666,15 @@ def _get_engineering_nearby( ), 4326 )::geography, - ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography - ) AS distance_m - FROM nspd_quarter_dumps d, - jsonb_array_elements(d.features_json) AS feat(value) - WHERE d.quarter_cad = :q - AND feat.value->>'layer' = 'engineering_structures' - AND (feat.value->'geometry') IS NOT NULL - AND feat.value->>'geometry' != 'null' - AND ST_DWithin( - ST_Transform( - ST_SetSRID( - ST_GeomFromGeoJSON(feat.value->>'geometry'), - 3857 - ), - 4326 - )::geography, - ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography, - :radius_m - ) - ORDER BY distance_m ASC - LIMIT 20 - """ - ), - {"q": quarter, "wkt": parcel_wkt, "radius_m": _ENGINEERING_RADIUS_M}, - ).fetchall() + ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography, + :radius_m + ) + ORDER BY distance_m ASC + LIMIT 20 + """ + ), + {"q": quarter, "wkt": parcel_wkt, "radius_m": _ENGINEERING_RADIUS_M}, + ).fetchall() result: list[dict[str, Any]] = [] for r in rows: @@ -750,53 +754,54 @@ def _get_risk_zones( if layer_counts is not None and layer_counts.get("risks_count", 1) == 0: return [] try: - rows = db.execute( - text( - """ - SELECT feat.value->>'layer' AS layer, - feat.value->'properties' AS props, - ST_AsText( - ST_Transform( - ST_SetSRID( - ST_GeomFromGeoJSON(feat.value->>'geometry'), - 3857 - ), - 4326 - ) - ) AS geom_wkt, - ST_Area( - ST_Intersection( + with db.begin_nested(): + rows = db.execute( + text( + """ + SELECT feat.value->>'layer' AS layer, + feat.value->'properties' AS props, + ST_AsText( ST_Transform( ST_SetSRID( ST_GeomFromGeoJSON(feat.value->>'geometry'), 3857 ), 4326 - )::geography, - ST_GeomFromText(:wkt, 4326)::geography - ) - ) AS intersection_area_sqm - FROM nspd_quarter_dumps d, - jsonb_array_elements(d.features_json) AS feat(value) - WHERE d.quarter_cad = :q - AND feat.value->>'layer' LIKE 'risk_%' - AND (feat.value->'geometry') IS NOT NULL - AND feat.value->>'geometry' != 'null' - AND ST_Intersects( - ST_Transform( - ST_SetSRID( - ST_GeomFromGeoJSON(feat.value->>'geometry'), - 3857 + ) + ) AS geom_wkt, + ST_Area( + ST_Intersection( + ST_Transform( + ST_SetSRID( + ST_GeomFromGeoJSON(feat.value->>'geometry'), + 3857 + ), + 4326 + )::geography, + ST_GeomFromText(:wkt, 4326)::geography + ) + ) AS intersection_area_sqm + FROM nspd_quarter_dumps d, + jsonb_array_elements(d.features_json) AS feat(value) + WHERE d.quarter_cad = :q + AND feat.value->>'layer' LIKE 'risk_%' + AND (feat.value->'geometry') IS NOT NULL + AND feat.value->>'geometry' != 'null' + AND ST_Intersects( + ST_Transform( + ST_SetSRID( + ST_GeomFromGeoJSON(feat.value->>'geometry'), + 3857 + ), + 4326 ), - 4326 - ), - ST_GeomFromText(:wkt, 4326) - ) - ORDER BY layer, intersection_area_sqm DESC NULLS LAST - """ - ), - {"q": quarter, "wkt": parcel_wkt}, - ).fetchall() + ST_GeomFromText(:wkt, 4326) + ) + ORDER BY layer, intersection_area_sqm DESC NULLS LAST + """ + ), + {"q": quarter, "wkt": parcel_wkt}, + ).fetchall() result: list[dict[str, Any]] = [] for r in rows: @@ -870,21 +875,38 @@ def _get_opportunity_parcels( if layer_counts is not None and layer_counts.get("opportunity_count", 1) == 0: return [] try: - rows = db.execute( - text( - """ - SELECT feat.value->>'layer' AS layer, - feat.value->'properties' AS props, - ST_AsText( - ST_Transform( - ST_SetSRID( - ST_GeomFromGeoJSON(feat.value->>'geometry'), - 3857 - ), - 4326 - ) - ) AS geom_wkt, - ST_Distance( + with db.begin_nested(): + rows = db.execute( + text( + """ + SELECT feat.value->>'layer' AS layer, + feat.value->'properties' AS props, + ST_AsText( + ST_Transform( + ST_SetSRID( + ST_GeomFromGeoJSON(feat.value->>'geometry'), + 3857 + ), + 4326 + ) + ) AS geom_wkt, + ST_Distance( + ST_Transform( + ST_SetSRID( + ST_GeomFromGeoJSON(feat.value->>'geometry'), + 3857 + ), + 4326 + )::geography, + ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography + ) AS distance_m + FROM nspd_quarter_dumps d, + jsonb_array_elements(d.features_json) AS feat(value) + WHERE d.quarter_cad = :q + AND feat.value->>'layer' LIKE 'opportunity_%' + AND (feat.value->'geometry') IS NOT NULL + AND feat.value->>'geometry' != 'null' + AND ST_DWithin( ST_Transform( ST_SetSRID( ST_GeomFromGeoJSON(feat.value->>'geometry'), @@ -892,31 +914,15 @@ def _get_opportunity_parcels( ), 4326 )::geography, - ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography - ) AS distance_m - FROM nspd_quarter_dumps d, - jsonb_array_elements(d.features_json) AS feat(value) - WHERE d.quarter_cad = :q - AND feat.value->>'layer' LIKE 'opportunity_%' - AND (feat.value->'geometry') IS NOT NULL - AND feat.value->>'geometry' != 'null' - AND ST_DWithin( - ST_Transform( - ST_SetSRID( - ST_GeomFromGeoJSON(feat.value->>'geometry'), - 3857 - ), - 4326 - )::geography, - ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography, - :radius_m - ) - ORDER BY distance_m ASC - LIMIT 30 - """ - ), - {"q": quarter, "wkt": parcel_wkt, "radius_m": _OPPORTUNITY_RADIUS_M}, - ).fetchall() + ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography, + :radius_m + ) + ORDER BY distance_m ASC + LIMIT 30 + """ + ), + {"q": quarter, "wkt": parcel_wkt, "radius_m": _OPPORTUNITY_RADIUS_M}, + ).fetchall() result: list[dict[str, Any]] = [] for r in rows: @@ -974,30 +980,20 @@ def _get_red_lines( if layer_counts is not None and layer_counts.get("red_lines_count", 1) == 0: return [] try: - rows = db.execute( - text( - """ - SELECT ST_AsText( - ST_Transform( - ST_SetSRID( - ST_GeomFromGeoJSON(feat.value->>'geometry'), - 3857 - ), - 4326 - ) - ) AS geom_wkt, - ST_Intersects( - ST_Transform( - ST_SetSRID( - ST_GeomFromGeoJSON(feat.value->>'geometry'), - 3857 - ), - 4326 - ), - ST_GeomFromText(:wkt, 4326) - ) AS does_intersect, - ST_Length( - ST_Intersection( + with db.begin_nested(): + rows = db.execute( + text( + """ + SELECT ST_AsText( + ST_Transform( + ST_SetSRID( + ST_GeomFromGeoJSON(feat.value->>'geometry'), + 3857 + ), + 4326 + ) + ) AS geom_wkt, + ST_Intersects( ST_Transform( ST_SetSRID( ST_GeomFromGeoJSON(feat.value->>'geometry'), @@ -1006,9 +1002,36 @@ def _get_red_lines( 4326 ), ST_GeomFromText(:wkt, 4326) - )::geography - ) AS intersection_length_m, - ST_Distance( + ) AS does_intersect, + ST_Length( + ST_Intersection( + ST_Transform( + ST_SetSRID( + ST_GeomFromGeoJSON(feat.value->>'geometry'), + 3857 + ), + 4326 + ), + ST_GeomFromText(:wkt, 4326) + )::geography + ) AS intersection_length_m, + ST_Distance( + ST_Transform( + ST_SetSRID( + ST_GeomFromGeoJSON(feat.value->>'geometry'), + 3857 + ), + 4326 + )::geography, + ST_GeomFromText(:wkt, 4326)::geography + ) AS distance_m + FROM nspd_quarter_dumps d, + jsonb_array_elements(d.features_json) AS feat(value) + WHERE d.quarter_cad = :q + AND feat.value->>'layer' = 'red_lines' + AND (feat.value->'geometry') IS NOT NULL + AND feat.value->>'geometry' != 'null' + AND ST_DWithin( ST_Transform( ST_SetSRID( ST_GeomFromGeoJSON(feat.value->>'geometry'), @@ -1016,31 +1039,15 @@ def _get_red_lines( ), 4326 )::geography, - ST_GeomFromText(:wkt, 4326)::geography - ) AS distance_m - FROM nspd_quarter_dumps d, - jsonb_array_elements(d.features_json) AS feat(value) - WHERE d.quarter_cad = :q - AND feat.value->>'layer' = 'red_lines' - AND (feat.value->'geometry') IS NOT NULL - AND feat.value->>'geometry' != 'null' - AND ST_DWithin( - ST_Transform( - ST_SetSRID( - ST_GeomFromGeoJSON(feat.value->>'geometry'), - 3857 - ), - 4326 - )::geography, - ST_GeomFromText(:wkt, 4326)::geography, - :nearby_m - ) - ORDER BY distance_m ASC - LIMIT 50 - """ - ), - {"q": quarter, "wkt": parcel_wkt, "nearby_m": _RED_LINES_NEARBY_M}, - ).fetchall() + ST_GeomFromText(:wkt, 4326)::geography, + :nearby_m + ) + ORDER BY distance_m ASC + LIMIT 50 + """ + ), + {"q": quarter, "wkt": parcel_wkt, "nearby_m": _RED_LINES_NEARBY_M}, + ).fetchall() result: list[dict[str, Any]] = [] for r in rows: @@ -1275,44 +1282,46 @@ def _get_engineering_structures_by_boundary( """ params = {"q": quarter, "wkt": parcel_wkt, "radius_m": radius_m} try: - total_count = ( - db.execute( + with db.begin_nested(): + total_count = ( + db.execute( + text( + "SELECT COUNT(*)" + " FROM nspd_quarter_dumps d," + " jsonb_array_elements(d.features_json) AS feat(value)" + " WHERE d.quarter_cad = :q" + _ENGINEERING_STRUCTURES_WHERE + ), + params, + ).scalar() + or 0 + ) + with db.begin_nested(): + rows = db.execute( text( - "SELECT COUNT(*)" - " FROM nspd_quarter_dumps d," - " jsonb_array_elements(d.features_json) AS feat(value)" - " WHERE d.quarter_cad = :q" + _ENGINEERING_STRUCTURES_WHERE + """ + SELECT feat.value->'properties' AS props, + feat.value->>'geometry' AS geom_json, + ST_Distance( + ST_Transform( + ST_SetSRID( + ST_GeomFromGeoJSON(feat.value->>'geometry'), + 3857 + ), + 4326 + )::geography, + ST_GeomFromText(:wkt, 4326)::geography + ) AS distance_m + FROM nspd_quarter_dumps d, + jsonb_array_elements(d.features_json) AS feat(value) + WHERE d.quarter_cad = :q""" + + _ENGINEERING_STRUCTURES_WHERE + + """ + ORDER BY distance_m ASC + LIMIT :items_limit + """ ), - params, - ).scalar() - or 0 - ) - rows = db.execute( - text( - """ - SELECT feat.value->'properties' AS props, - feat.value->>'geometry' AS geom_json, - ST_Distance( - ST_Transform( - ST_SetSRID( - ST_GeomFromGeoJSON(feat.value->>'geometry'), - 3857 - ), - 4326 - )::geography, - ST_GeomFromText(:wkt, 4326)::geography - ) AS distance_m - FROM nspd_quarter_dumps d, - jsonb_array_elements(d.features_json) AS feat(value) - WHERE d.quarter_cad = :q""" - + _ENGINEERING_STRUCTURES_WHERE - + """ - ORDER BY distance_m ASC - LIMIT :items_limit - """ - ), - {**params, "items_limit": _ENGINEERING_STRUCTURES_ITEMS_LIMIT}, - ).fetchall() + {**params, "items_limit": _ENGINEERING_STRUCTURES_ITEMS_LIMIT}, + ).fetchall() except (OperationalError, ProgrammingError, DataError) as e: # DataError — malformed WKT в ST_GeomFromText / ST_GeomFromGeoJSON (PostGIS ERROR). logger.warning( @@ -1466,35 +1475,37 @@ def _get_zouit_engineering_overlaps( """ params = {"q": quarter, "wkt": parcel_wkt} try: - total_count = ( - db.execute( + with db.begin_nested(): + total_count = ( + db.execute( + text( + "SELECT COUNT(*)" + " FROM nspd_quarter_dumps d," + " jsonb_array_elements(d.features_json) AS feat(value)" + " WHERE d.quarter_cad = :q" + _ZOUIT_ENGINEERING_WHERE + ), + params, + ).scalar() + or 0 + ) + with db.begin_nested(): + rows = db.execute( text( - "SELECT COUNT(*)" - " FROM nspd_quarter_dumps d," - " jsonb_array_elements(d.features_json) AS feat(value)" - " WHERE d.quarter_cad = :q" + _ZOUIT_ENGINEERING_WHERE + """ + SELECT feat.value->'properties' AS props, + feat.value->>'geometry' AS geom_json + FROM nspd_quarter_dumps d, + jsonb_array_elements(d.features_json) AS feat(value) + WHERE d.quarter_cad = :q""" + + _ZOUIT_ENGINEERING_WHERE + + """ + ORDER BY feat.value->'properties'->>'reg_numb_border' NULLS LAST, + feat.value->>'geometry' + LIMIT :items_limit + """ ), - params, - ).scalar() - or 0 - ) - rows = db.execute( - text( - """ - SELECT feat.value->'properties' AS props, - feat.value->>'geometry' AS geom_json - FROM nspd_quarter_dumps d, - jsonb_array_elements(d.features_json) AS feat(value) - WHERE d.quarter_cad = :q""" - + _ZOUIT_ENGINEERING_WHERE - + """ - ORDER BY feat.value->'properties'->>'reg_numb_border' NULLS LAST, - feat.value->>'geometry' - LIMIT :items_limit - """ - ), - {**params, "items_limit": _ZOUIT_ENGINEERING_ITEMS_LIMIT}, - ).fetchall() + {**params, "items_limit": _ZOUIT_ENGINEERING_ITEMS_LIMIT}, + ).fetchall() except (OperationalError, ProgrammingError, DataError) as e: # DataError — malformed WKT в ST_GeomFromText / ST_GeomFromGeoJSON (PostGIS ERROR). logger.warning( diff --git a/backend/tests/services/site_finder/test_quarter_dump_savepoint.py b/backend/tests/services/site_finder/test_quarter_dump_savepoint.py new file mode 100644 index 00000000..066d384b --- /dev/null +++ b/backend/tests/services/site_finder/test_quarter_dump_savepoint.py @@ -0,0 +1,224 @@ +"""SAVEPOINT-регрессия для quarter_dump_lookup.py (#2464 cluster A). + +_get_zoning / _get_zouit_overlaps / _get_cad_zouit_overlaps / _get_engineering_nearby / +_get_risk_zones / _get_opportunity_parcels / _get_red_lines / _get_engineering_structures_ +by_boundary когда-то ловили db.execute-сбой в bare ``except Exception`` (или +``except (OperationalError, ProgrammingError, DataError)``) БЕЗ SAVEPOINT/rollback — на +реальном Postgres это отравляет db shared с caller'ом (`get_quarter_dump_data(db, ...)` → +вызывается из `/analyze` эндпоинта, `Depends(get_db)`): следующий `db.execute` в этом же +запросе (сиблинг `_get_*` хелпер, либо downstream `get_or_fetch_zone_regulation`, либо +`begin_nested` "9f cad_parcels" блок) падает с "current transaction is aborted" / +PendingRollbackError. Референс-корректный сиблинг — `connection_capacity_lookup.py:: +_query_nearby_network_zones` (уже был обёрнут в `db.begin_nested()`). + +MagicMock-сессия не эмулирует реальный aborted-transaction Postgres — тесты проверяют +(1) graceful fallback при сбое (документированный контракт функции не меняется), +(2) что begin_nested() реально вызван вокруг db.execute (SAVEPOINT-обёртка присутствует +в коде), (3) что db остаётся usable для следующего вызова на той же mock-сессии — ошибка +не проглатывает control flow и не роняет остальную сессию. + +Покрывает представительное подмножество (per #2464 audit): _get_zoning, _get_zouit_overlaps, +_get_engineering_structures_by_boundary и _get_zouit_engineering_overlaps (последние два — +2 db.execute на функцию, каждый под своей savepoint, epic #2445 A1 honest-count + row-fetch). +""" + +from __future__ import annotations + +import os +from unittest.mock import MagicMock + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from sqlalchemy.exc import OperationalError + +from app.services.site_finder.quarter_dump_lookup import ( + _get_engineering_structures_by_boundary, + _get_zoning, + _get_zouit_engineering_overlaps, + _get_zouit_overlaps, +) + + +def _op_error(msg: str) -> OperationalError: + """OperationalError фабрика — единственный тип, который ловит except + (OperationalError, ProgrammingError, DataError) в _get_engineering_structures_ + by_boundary (bare RuntimeError туда НЕ провалится — это намеренно узкий except).""" + return OperationalError(msg, {}, Exception(msg)) + + +_QUARTER = "66:41:0204016" +_WKT = "POLYGON((60.6 56.8, 60.7 56.8, 60.7 56.9, 60.6 56.9, 60.6 56.8))" + + +class TestGetZoningSavepoint: + """_get_zoning: db.execute().first(), except Exception → return None.""" + + def test_query_failure_degrades_to_none(self) -> None: + db = MagicMock() + db.execute.side_effect = RuntimeError("nspd_quarter_dumps query failed") + assert _get_zoning(db, _QUARTER, _WKT) is None + + def test_uses_begin_nested_savepoint(self) -> None: + db = MagicMock() + db.execute.return_value.first.return_value = None + _get_zoning(db, _QUARTER, _WKT) + assert db.begin_nested.call_count == 1 + + def test_session_usable_after_failure(self) -> None: + """db.execute сбоит один раз, но db остаётся usable для следующего + (не связанного) вызова на этой же сессии — имитирует sibling _get_* + хелпер, вызванный сразу после в get_quarter_dump_data().""" + db = MagicMock() + db.execute.side_effect = RuntimeError("boom") + + result = _get_zoning(db, _QUARTER, _WKT) + + assert result is None + # Следующий вызов на том же db.execute отрабатывает (сессия не отравлена). + db.execute.side_effect = None + next_result = MagicMock() + next_result.first.return_value = ("marker",) + db.execute.return_value = next_result + assert db.execute("SELECT 1").first() == ("marker",) + + +class TestGetZouitOverlapsSavepoint: + """_get_zouit_overlaps: db.execute().fetchall(), except Exception → return [].""" + + def test_query_failure_degrades_to_empty_list(self) -> None: + db = MagicMock() + db.execute.side_effect = RuntimeError("nspd zouit query failed") + result = _get_zouit_overlaps(db, _QUARTER, _WKT, {"zouit_count": 3}) + assert result == [] + + def test_uses_begin_nested_savepoint(self) -> None: + db = MagicMock() + db.execute.return_value.fetchall.return_value = [] + _get_zouit_overlaps(db, _QUARTER, _WKT, {"zouit_count": 3}) + assert db.begin_nested.call_count == 1 + + def test_session_usable_after_failure(self) -> None: + """Сбой на dump-path (zouit_count>0) не отравляет сессию для следующего + sibling-запроса (например _get_engineering_nearby сразу после).""" + db = MagicMock() + db.execute.side_effect = RuntimeError("boom") + + result = _get_zouit_overlaps(db, _QUARTER, _WKT, {"zouit_count": 3}) + + assert result == [] + db.execute.side_effect = None + next_result = MagicMock() + next_result.fetchall.return_value = [("marker",)] + db.execute.return_value = next_result + assert db.execute("SELECT 1").fetchall() == [("marker",)] + + +class TestGetEngineeringStructuresByBoundarySavepoint: + """_get_engineering_structures_by_boundary: 2 db.execute (COUNT + row-fetch) в одном + try, except (OperationalError, ProgrammingError, DataError) → return ([], 0). + + Каждый db.execute нуждается в СОБСТВЕННОЙ savepoint (RELEASE SAVEPOINT trap): если + COUNT сбоит, ROLLBACK TO SAVEPOINT должен откатить только COUNT, не всю функцию — + и наоборот, если COUNT прошёл, а row-fetch сбоил, savepoint row-fetch'а изолирует + именно его сбой. + """ + + def test_count_query_failure_degrades_to_empty(self) -> None: + db = MagicMock() + db.execute.side_effect = _op_error("COUNT(*) query failed") + items, total = _get_engineering_structures_by_boundary(db, _QUARTER, _WKT, 500) + assert items == [] + assert total == 0 + + def test_rowfetch_failure_after_successful_count_degrades_to_empty(self) -> None: + """COUNT succeeds, row-fetch fails — fallback ([], 0), не total_count-протечка.""" + db = MagicMock() + count_result = MagicMock() + count_result.scalar.return_value = 42 + db.execute.side_effect = [count_result, _op_error("row-fetch failed")] + items, total = _get_engineering_structures_by_boundary(db, _QUARTER, _WKT, 500) + assert items == [] + assert total == 0 + + def test_uses_two_separate_begin_nested_savepoints(self) -> None: + """COUNT и row-fetch — каждый под своей savepoint (2 отдельных begin_nested).""" + db = MagicMock() + count_result = MagicMock() + count_result.scalar.return_value = 0 + rows_result = MagicMock() + rows_result.fetchall.return_value = [] + db.execute.side_effect = [count_result, rows_result] + + _get_engineering_structures_by_boundary(db, _QUARTER, _WKT, 500) + + assert db.begin_nested.call_count == 2 + + def test_session_usable_after_count_failure(self) -> None: + """COUNT сбоит → db остаётся usable для следующего sibling-запроса + (например _get_zouit_engineering_overlaps сразу после в get_connection_points).""" + db = MagicMock() + db.execute.side_effect = _op_error("boom") + + items, total = _get_engineering_structures_by_boundary(db, _QUARTER, _WKT, 500) + + assert (items, total) == ([], 0) + db.execute.side_effect = None + next_result = MagicMock() + next_result.fetchall.return_value = [("marker",)] + db.execute.return_value = next_result + assert db.execute("SELECT 1").fetchall() == [("marker",)] + + +class TestGetZouitEngineeringOverlapsSavepoint: + """_get_zouit_engineering_overlaps: 2 db.execute (COUNT + row-fetch) в одном try, + except (OperationalError, ProgrammingError, DataError) → return ([], 0). + + Идентичная 2-запросная форма _get_engineering_structures_by_boundary — вызывается + сразу после него в get_connection_points() на той же shared session, поэтому каждый + db.execute обёрнут в свою savepoint (RELEASE SAVEPOINT trap, epic #2445 A1). + """ + + def test_count_query_failure_degrades_to_empty(self) -> None: + db = MagicMock() + db.execute.side_effect = _op_error("COUNT(*) query failed") + items, total = _get_zouit_engineering_overlaps(db, _QUARTER, _WKT) + assert items == [] + assert total == 0 + + def test_rowfetch_failure_after_successful_count_degrades_to_empty(self) -> None: + """COUNT succeeds, row-fetch fails — fallback ([], 0), не total_count-протечка.""" + db = MagicMock() + count_result = MagicMock() + count_result.scalar.return_value = 137 + db.execute.side_effect = [count_result, _op_error("row-fetch failed")] + items, total = _get_zouit_engineering_overlaps(db, _QUARTER, _WKT) + assert items == [] + assert total == 0 + + def test_uses_two_separate_begin_nested_savepoints(self) -> None: + """COUNT и row-fetch — каждый под своей savepoint (2 отдельных begin_nested).""" + db = MagicMock() + count_result = MagicMock() + count_result.scalar.return_value = 0 + rows_result = MagicMock() + rows_result.fetchall.return_value = [] + db.execute.side_effect = [count_result, rows_result] + + _get_zouit_engineering_overlaps(db, _QUARTER, _WKT) + + assert db.begin_nested.call_count == 2 + + def test_session_usable_after_failure(self) -> None: + """Сбой не отравляет сессию — get_connection_points после этого хелпера ещё + строит summary и (в /analyze-контексте) сессия shared с downstream-запросами.""" + db = MagicMock() + db.execute.side_effect = _op_error("boom") + + items, total = _get_zouit_engineering_overlaps(db, _QUARTER, _WKT) + + assert (items, total) == ([], 0) + db.execute.side_effect = None + next_result = MagicMock() + next_result.fetchall.return_value = [("marker",)] + db.execute.return_value = next_result + assert db.execute("SELECT 1").fetchall() == [("marker",)]