fix(sf): per-quarter commit в ird/opportunity harvest — не терять прогресс при краше (#1067)
All checks were successful
CI / changes (pull_request) Successful in 6s
CI / changes (push) Successful in 6s
CI / backend-tests (push) Successful in 5m47s
CI / backend-tests (pull_request) Successful in 5m44s

This commit is contained in:
bot-backend 2026-06-07 00:43:04 +03:00
parent dee7d64ac8
commit fe452e3f31
3 changed files with 48 additions and 2 deletions

View file

@ -211,10 +211,14 @@ def harvest_ird_overlays(quarters: list[str] | None = None) -> dict[str, int]:
fetched_at=fetched_at, fetched_at=fetched_at,
): ):
n_features += 1 n_features += 1
# Durable per-quarter commit: длинный grid-walk (часы) не теряет прогресс при
# краше/таймауте/WorkerLost середины прогона (commit раз-в-конце терял ВСЁ).
db.commit()
except Exception as exc: except Exception as exc:
logger.warning("ird_harvest: квартал %s failed: %s", quarter, exc) logger.warning("ird_harvest: квартал %s failed: %s", quarter, exc)
db.rollback() # сбросить незакоммиченный tx квартала перед следующим
continue continue
db.commit() db.commit() # финальный — на случай пустого списка / подстраховка
logger.info("ird_harvest: готово, кварталов=%d фич=%d", n_quarters, n_features) logger.info("ird_harvest: готово, кварталов=%d фич=%d", n_quarters, n_features)
return {"quarters": n_quarters, "features": n_features} return {"quarters": n_quarters, "features": n_features}
finally: finally:

View file

@ -95,10 +95,14 @@ def harvest_opportunity_overlays(quarters: list[str] | None = None) -> dict[str,
fetched_at=fetched_at, fetched_at=fetched_at,
): ):
n_features += 1 n_features += 1
# Durable per-quarter commit: длинный grid-walk не теряет прогресс при
# краше/таймауте середины прогона (commit раз-в-конце терял ВСЁ).
db.commit()
except Exception as exc: except Exception as exc:
logger.warning("opportunity_harvest: квартал %s failed: %s", quarter, exc) logger.warning("opportunity_harvest: квартал %s failed: %s", quarter, exc)
db.rollback() # сбросить незакоммиченный tx квартала перед следующим
continue continue
db.commit() db.commit() # финальный — на случай пустого списка / подстраховка
logger.info("opportunity_harvest: готово, кварталов=%d фич=%d", n_quarters, n_features) logger.info("opportunity_harvest: готово, кварталов=%d фич=%d", n_quarters, n_features)
return {"quarters": n_quarters, "features": n_features} return {"quarters": n_quarters, "features": n_features}
finally: finally:

View file

@ -55,6 +55,8 @@ class _FakeDB:
def __init__(self) -> None: def __init__(self) -> None:
self.executed: list[tuple[Any, dict[str, Any] | None]] = [] self.executed: list[tuple[Any, dict[str, Any] | None]] = []
self.committed = False self.committed = False
self.commit_count = 0
self.rollback_count = 0
self.closed = False self.closed = False
@contextmanager @contextmanager
@ -67,6 +69,10 @@ class _FakeDB:
def commit(self) -> None: def commit(self) -> None:
self.committed = True self.committed = True
self.commit_count += 1
def rollback(self) -> None:
self.rollback_count += 1
def close(self) -> None: def close(self) -> None:
self.closed = True self.closed = True
@ -131,3 +137,35 @@ def test_upsert_feature_skips_missing_geom_data_id(monkeypatch: Any) -> None:
is False is False
) )
assert db.executed == [] assert db.executed == []
def test_per_quarter_commit_durability(monkeypatch: Any) -> None:
"""Каждый квартал коммитится отдельно → краш середины не теряет прошлые (фикс #1067)."""
db = _FakeDB()
_patch(monkeypatch, db)
result = ird_harvest.harvest_ird_overlays(quarters=["66:41:0000001", "66:41:0000002"])
assert result["quarters"] == 2
# commit per-quarter (2) + финальный = >=2; точно больше одного (не «раз в конце»)
assert db.commit_count >= 2
def test_quarter_failure_rolls_back_and_continues(monkeypatch: Any) -> None:
"""Сбой одного квартала → rollback + продолжение; остальные кварталы коммитятся."""
db = _FakeDB()
client = _patch(monkeypatch, db)
def _flaky(cad: str, thematic_id: int = 1) -> Any:
if cad == "66:41:0000001":
raise RuntimeError("NSPD timeout")
ring = [[0, 0], [100, 0], [100, 100], [0, 100], [0, 0]]
return _SearchRes(_Feat({"type": "Polygon", "coordinates": [ring]}, {"cad": cad}))
monkeypatch.setattr(client, "search_by_cad", _flaky)
result = ird_harvest.harvest_ird_overlays(quarters=["66:41:0000001", "66:41:0000002"])
assert db.rollback_count >= 1 # битый квартал откатился
assert result["quarters"] == 1 # второй квартал обработан
assert db.committed is True