From c3cafefb1a1c1a0d07c1e086a72cbf1bf8670f5f Mon Sep 17 00:00:00 2001 From: lekss361 Date: Fri, 15 May 2026 08:40:48 +0300 Subject: [PATCH 1/2] =?UTF-8?q?fix(site-finder):=20db.rollback()=20=D0=B2?= =?UTF-8?q?=20velocity=20catches=20+=20SAVEPOINT=20zoning/success=5Frec?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit velocity.py 3 internal try/except возвращали None БЕЗ db.rollback(). PR #154 SAVEPOINT не помог — exception не propagates из velocity. Postgres tx aborted внутри, cascade InFailedSqlTransaction на следующих queries. Fix: - velocity.py: db.rollback() в 3 catch блоках - parcels.py: SAVEPOINT обёртки для zoning + success_recommendation Refs: user reports 2026-05-15 cascade 500 --- backend/app/api/v1/parcels.py | 116 ++++++++++--------- backend/app/services/site_finder/velocity.py | 17 +++ 2 files changed, 76 insertions(+), 57 deletions(-) diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 8b65121f..0e18adc4 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -1658,33 +1658,34 @@ def analyze_parcel( "lon": centroid_lon, } try: - zoning_row = ( - db.execute( - text(""" - SELECT zone_code, zone_name, description, rosreestr_id - FROM pzz_zones_ekb - WHERE ST_Within( - ST_Centroid(ST_GeomFromText(:wkt, 4326)), - geom - ) - LIMIT 1 - """), - {"wkt": geom_wkt}, - ) - .mappings() - .first() - ) - if zoning_row: - zoning.update( - { - "zone_code": zoning_row["zone_code"], - "zone_name": zoning_row["zone_name"], - "description": zoning_row["description"], - "rosreestr_id": zoning_row["rosreestr_id"], - "data_available": True, - "source": "rosreestr-pkk6-cached", - } + with db.begin_nested(): + zoning_row = ( + db.execute( + text(""" + SELECT zone_code, zone_name, description, rosreestr_id + FROM pzz_zones_ekb + WHERE ST_Within( + ST_Centroid(ST_GeomFromText(:wkt, 4326)), + geom + ) + LIMIT 1 + """), + {"wkt": geom_wkt}, + ) + .mappings() + .first() ) + if zoning_row: + zoning.update( + { + "zone_code": zoning_row["zone_code"], + "zone_name": zoning_row["zone_name"], + "description": zoning_row["description"], + "rosreestr_id": zoning_row["rosreestr_id"], + "data_available": True, + "source": "rosreestr-pkk6-cached", + } + ) except Exception as e: logger.warning("zoning query failed for %s: %s", cad_num, e) @@ -1692,9 +1693,10 @@ def analyze_parcel( success_recommendation: dict[str, Any] | None = None if district_row: try: - success_rows = ( - db.execute( - text(""" + with db.begin_nested(): + success_rows = ( + db.execute( + text(""" SELECT bucket, success_score, n_deals, avg_price_per_m2, avg_area_m2, velocity_z, price_z, area_z FROM v_bucket_success_score @@ -1702,35 +1704,35 @@ def analyze_parcel( ORDER BY success_score DESC LIMIT 5 """), - {"dn": district_row["district_name"]}, + {"dn": district_row["district_name"]}, + ) + .mappings() + .all() ) - .mappings() - .all() - ) - if success_rows: - success_recommendation = { - "district": district_row["district_name"], - "ranking": [ - { - "bucket": r["bucket"], - "success_score": round(float(r["success_score"]), 2), - "n_deals": int(r["n_deals"]), - "avg_price_per_m2": ( - int(r["avg_price_per_m2"]) if r["avg_price_per_m2"] else None - ), - "avg_area_m2": ( - round(float(r["avg_area_m2"]), 1) if r["avg_area_m2"] else None - ), - } - for r in success_rows - ], - "top_bucket": dict(success_rows[0]) if success_rows else None, - "note": ( - "Топ комнатность по 'успешности' = z-scores: velocity×0.5 + price×0.3 " - "- area×0.2. Min 30 сделок в группе за 24 мес. " - "Используй для квартирографии проекта." - ), - } + if success_rows: + success_recommendation = { + "district": district_row["district_name"], + "ranking": [ + { + "bucket": r["bucket"], + "success_score": round(float(r["success_score"]), 2), + "n_deals": int(r["n_deals"]), + "avg_price_per_m2": ( + int(r["avg_price_per_m2"]) if r["avg_price_per_m2"] else None + ), + "avg_area_m2": ( + round(float(r["avg_area_m2"]), 1) if r["avg_area_m2"] else None + ), + } + for r in success_rows + ], + "top_bucket": dict(success_rows[0]) if success_rows else None, + "note": ( + "Топ комнатность по 'успешности' = z-scores: velocity×0.5 + price×0.3 " + "- area×0.2. Min 30 сделок в группе за 24 мес. " + "Используй для квартирографии проекта." + ), + } except Exception as e: logger.warning("success_recommendation query failed for %s: %s", cad_num, e) success_recommendation = None diff --git a/backend/app/services/site_finder/velocity.py b/backend/app/services/site_finder/velocity.py index e7de6ec0..c1766a1c 100644 --- a/backend/app/services/site_finder/velocity.py +++ b/backend/app/services/site_finder/velocity.py @@ -126,6 +126,13 @@ def compute_velocity( ) except Exception: logger.exception("velocity: competitor query failed for wkt=%s", parcel_geom_wkt[:80]) + # КРИТИЧНО: rollback aborted transaction чтобы caller (analyze_parcel) + # мог продолжить выполнять последующие queries. Иначе caller получает + # cascade InFailedSqlTransaction на следующей SQL execute. + try: + db.rollback() + except Exception: + pass return None if not comp_rows: @@ -186,6 +193,11 @@ def compute_velocity( ) except Exception: logger.exception("velocity: sale_graph query failed for obj_ids=%s", obj_ids[:5]) + # КРИТИЧНО: rollback aborted transaction (см. competitor query комментарий) + try: + db.rollback() + except Exception: + pass return None if not sales_rows: @@ -311,6 +323,11 @@ def _get_ekb_median(db: Session, months_window: int = 6) -> float | None: ) except Exception: logger.warning("velocity: ekb_median query failed, using fallback") + # КРИТИЧНО: rollback aborted transaction + try: + db.rollback() + except Exception: + pass return None if row and row["median"] is not None: -- 2.45.3 From 37fd4b5a8ec01d483f980a5149c47f6225b46dee Mon Sep 17 00:00:00 2001 From: lekss361 Date: Fri, 15 May 2026 08:49:28 +0300 Subject: [PATCH 2/2] =?UTF-8?q?fix(velocity):=20SAVEPOINT=20per=20query=20?= =?UTF-8?q?(not=20db.rollback)=20=E2=80=94=20correct=20SQLAlchemy=202.0=20?= =?UTF-8?q?pattern?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per PR #155 bot review — мой db.rollback() в catches был НЕПРАВИЛЬНЫЙ. Session.rollback() = ENTIRE outer tx (не savepoint). Outer SAVEPOINT context становится orphaned → InvalidRequestError на __exit__. Correct fix: wrap каждый db.execute в свой with db.begin_nested(): - Failure → __exit__ propagates exception → SAVEPOINT rolls back - Outer tx остаётся clean - velocity returns None gracefully → caller продолжает Все 3 SQL queries в velocity.py теперь SAVEPOINT-wrapped: - compute_velocity: competitor_query - compute_velocity: sales_rows query - _get_ekb_median: median query db.rollback() removed из всех 3 catches. Refs: PR #155 bot review --- backend/app/services/site_finder/velocity.py | 95 +++++++++----------- 1 file changed, 44 insertions(+), 51 deletions(-) diff --git a/backend/app/services/site_finder/velocity.py b/backend/app/services/site_finder/velocity.py index c1766a1c..7ba7f9e2 100644 --- a/backend/app/services/site_finder/velocity.py +++ b/backend/app/services/site_finder/velocity.py @@ -74,11 +74,15 @@ def compute_velocity( # obj_class в domrf_kn_objects заполнен слабо (много NULL); фильтруем # только если явно передан. class_filter = "AND o.obj_class = :obj_class" if obj_class else "" + # SAVEPOINT per query: failure rollbacks ТОЛЬКО savepoint, не outer tx. + # db.rollback() здесь НЕЛЬЗЯ — он orphan'ит outer SessionTransaction + # (см. PR #155 bot review — SQLAlchemy 2.0 begin_nested context cleanup). try: - comp_rows = ( - db.execute( - text( - f""" + with db.begin_nested(): + comp_rows = ( + db.execute( + text( + f""" WITH latest_obj AS ( SELECT DISTINCT ON (obj_id) obj_id, @@ -114,25 +118,20 @@ def compute_velocity( ORDER BY distance_m ASC LIMIT 200 """ - ), - { - "parcel_wkt": parcel_geom_wkt, - "radius_m": radius_km * 1000.0, - "obj_class": obj_class, - }, + ), + { + "parcel_wkt": parcel_geom_wkt, + "radius_m": radius_km * 1000.0, + "obj_class": obj_class, + }, + ) + .mappings() + .all() ) - .mappings() - .all() - ) except Exception: logger.exception("velocity: competitor query failed for wkt=%s", parcel_geom_wkt[:80]) - # КРИТИЧНО: rollback aborted transaction чтобы caller (analyze_parcel) - # мог продолжить выполнять последующие queries. Иначе caller получает - # cascade InFailedSqlTransaction на следующей SQL execute. - try: - db.rollback() - except Exception: - pass + # SAVEPOINT auto-rollbacks через __exit__ context manager. + # Outer tx остаётся clean — caller продолжает работать без cascade. return None if not comp_rows: @@ -154,10 +153,11 @@ def compute_velocity( # area_sq = м² за месяц (primary). Если NULL — realised * 45 м² heuristic. # type = 'apartments' — только жильё. try: - sales_rows = ( - db.execute( - text( - """ + with db.begin_nested(): + sales_rows = ( + db.execute( + text( + """ WITH latest_sg AS ( SELECT DISTINCT ON (obj_id, report_month) obj_id, @@ -182,22 +182,18 @@ def compute_velocity( WHERE area_sq > 0 OR realised > 0 GROUP BY obj_id """ - ), - { - "obj_ids": obj_ids, - "window_interval": f"{months_window} months", - }, + ), + { + "obj_ids": obj_ids, + "window_interval": f"{months_window} months", + }, + ) + .mappings() + .all() ) - .mappings() - .all() - ) except Exception: logger.exception("velocity: sale_graph query failed for obj_ids=%s", obj_ids[:5]) - # КРИТИЧНО: rollback aborted transaction (см. competitor query комментарий) - try: - db.rollback() - except Exception: - pass + # SAVEPOINT auto-rollback'нут — outer tx clean return None if not sales_rows: @@ -278,10 +274,11 @@ def _get_ekb_median(db: Session, months_window: int = 6) -> float | None: Fallback к _EKB_MEDIAN_FALLBACK_SQM_PER_MONTH если нет данных в БД. """ try: - row = ( - db.execute( - text( - """ + with db.begin_nested(): + row = ( + db.execute( + text( + """ WITH latest_sg AS ( SELECT DISTINCT ON (obj_id, report_month) obj_id, @@ -315,19 +312,15 @@ def _get_ekb_median(db: Session, months_window: int = 6) -> float | None: SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY velocity) AS median FROM per_obj_velocity """ - ), - {"window_interval": f"{months_window} months"}, + ), + {"window_interval": f"{months_window} months"}, + ) + .mappings() + .first() ) - .mappings() - .first() - ) except Exception: logger.warning("velocity: ekb_median query failed, using fallback") - # КРИТИЧНО: rollback aborted transaction - try: - db.rollback() - except Exception: - pass + # SAVEPOINT auto-rollback'нут return None if row and row["median"] is not None: -- 2.45.3