fix(backend): wrap quarter-dump lookup db.execute in SAVEPOINTs (#2464)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m2s
CI / backend-tests (pull_request) Successful in 15m1s
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m2s
CI / backend-tests (pull_request) Successful in 15m1s
11 db.execute swallow sites across the _get_* helpers in quarter_dump_lookup.py caught failures without a savepoint, aborting the shared /analyze request session for sibling helpers and downstream queries (get_or_fetch_zone_regulation, the 9f cad_parcels begin_nested block). Wrap each in db.begin_nested(); _get_engineering_structures_by_ boundary and _get_zouit_engineering_overlaps get two independent savepoints (one per db.execute) per the RELEASE SAVEPOINT trap. get_connection_points and _get_parcel_wkt confirmed already-safe (their db.execute propagates, not swallows). Adds regression tests. Refs #2464 (cluster A session-poisoning, Wave 2).
This commit is contained in:
parent
82fdabccba
commit
7f0ea1bb8b
2 changed files with 545 additions and 310 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",)]
|
||||
Loading…
Add table
Reference in a new issue