From a7617a2cc45946a2ad9a05336537553df585faae Mon Sep 17 00:00:00 2001 From: lekss361 Date: Thu, 14 May 2026 23:22:53 +0300 Subject: [PATCH] fix(pzz_loader): replace bare db.rollback() with SAVEPOINT per-row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per code review audit (May 14): transaction correctness bug в `sync_pzz_zones_to_db()` — inner `db.rollback()` в loop сбрасывал outer transaction на per-row failures → counts inserted/updated НЕ matched DB state. ## Fix Wrap UPSERT в loop через `db.begin_nested()` (SAVEPOINT): - Failure одной row → rollback только её savepoint, outer tx alive - Successfully inserted rows accumulate в outer tx → commit final - Outer try/except добавлен для unexpected error → rollback + log + raise - `finally: db.close()` preserved Pattern consistent с `domrf_kn.py` (где SAVEPOINT уже работает). ## Preserved - Function signature + return shape {fetched, inserted, updated, skipped} — unchanged - Logging contracts - skipped count теперь корректно включает DB-failure rows (consistent with prior behavior) ## Tests - No test_pzz_loader.py — skipped - ruff + AST passed ## Vault `fixes/Bug_Pzz_Loader_Missing_Savepoint_May14.md` — created (status: resolved). --- .../app/services/site_finder/pzz_loader.py | 62 +++++++++++-------- 1 file changed, 35 insertions(+), 27 deletions(-) diff --git a/backend/app/services/site_finder/pzz_loader.py b/backend/app/services/site_finder/pzz_loader.py index 2274a53e..7d3c3a12 100644 --- a/backend/app/services/site_finder/pzz_loader.py +++ b/backend/app/services/site_finder/pzz_loader.py @@ -80,6 +80,10 @@ def sync_pzz_zones_to_db() -> dict[str, int]: """Загрузить зоны из PKK6 и UPSERT в pzz_zones_ekb. Возвращает словарь {fetched, inserted, updated, skipped}. + + Каждый UPSERT обёрнут в SAVEPOINT (db.begin_nested), чтобы ошибка + одной записи не откатывала всю транзакцию и счётчики оставались + согласованными с тем, что реально записано в DB. """ features = asyncio.run(fetch_pkk6_zones()) inserted = 0 @@ -105,41 +109,45 @@ def sync_pzz_zones_to_db() -> dict[str, int]: ) description = props.get("description") try: - result = db.execute( - text(""" - INSERT INTO pzz_zones_ekb - (rosreestr_id, zone_code, zone_name, description, raw_props, geom) - VALUES ( - :rid, :code, :nm, :desc, CAST(:props AS jsonb), - ST_Multi(ST_SetSRID(ST_GeomFromGeoJSON(:g), 4326)) - ) - ON CONFLICT (rosreestr_id) DO UPDATE - SET zone_code = EXCLUDED.zone_code, - zone_name = EXCLUDED.zone_name, - description = EXCLUDED.description, - raw_props = EXCLUDED.raw_props, - geom = EXCLUDED.geom, - fetched_at = NOW() - RETURNING (xmax = 0) AS is_insert - """), - { - "rid": rosreestr_id, - "code": zone_code, - "nm": zone_name, - "desc": description, - "props": json.dumps(props, ensure_ascii=False), - "g": json.dumps(geom), - }, - ).scalar() + with db.begin_nested(): # SAVEPOINT — откат только этой записи + result = db.execute( + text(""" + INSERT INTO pzz_zones_ekb + (rosreestr_id, zone_code, zone_name, description, raw_props, geom) + VALUES ( + :rid, :code, :nm, :desc, CAST(:props AS jsonb), + ST_Multi(ST_SetSRID(ST_GeomFromGeoJSON(:g), 4326)) + ) + ON CONFLICT (rosreestr_id) DO UPDATE + SET zone_code = EXCLUDED.zone_code, + zone_name = EXCLUDED.zone_name, + description = EXCLUDED.description, + raw_props = EXCLUDED.raw_props, + geom = EXCLUDED.geom, + fetched_at = NOW() + RETURNING (xmax = 0) AS is_insert + """), + { + "rid": rosreestr_id, + "code": zone_code, + "nm": zone_name, + "desc": description, + "props": json.dumps(props, ensure_ascii=False), + "g": json.dumps(geom), + }, + ).scalar() if result: inserted += 1 else: updated += 1 except Exception as e: logger.warning("PZZ insert failed for rosreestr_id=%s: %s", rosreestr_id, e) - db.rollback() skipped += 1 db.commit() + except Exception as e: + db.rollback() + logger.exception("pzz_loader: unexpected error, outer tx rolled back: %s", e) + raise finally: db.close() return {