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: