diff --git a/backend/app/workers/tasks/ird_harvest.py b/backend/app/workers/tasks/ird_harvest.py index c721ed83..42f50a96 100644 --- a/backend/app/workers/tasks/ird_harvest.py +++ b/backend/app/workers/tasks/ird_harvest.py @@ -211,10 +211,14 @@ def harvest_ird_overlays(quarters: list[str] | None = None) -> dict[str, int]: fetched_at=fetched_at, ): n_features += 1 + # Durable per-quarter commit: длинный grid-walk (часы) не теряет прогресс при + # краше/таймауте/WorkerLost середины прогона (commit раз-в-конце терял ВСЁ). + db.commit() except Exception as exc: logger.warning("ird_harvest: квартал %s failed: %s", quarter, exc) + db.rollback() # сбросить незакоммиченный tx квартала перед следующим continue - db.commit() + db.commit() # финальный — на случай пустого списка / подстраховка logger.info("ird_harvest: готово, кварталов=%d фич=%d", n_quarters, n_features) return {"quarters": n_quarters, "features": n_features} finally: diff --git a/backend/app/workers/tasks/opportunity_harvest.py b/backend/app/workers/tasks/opportunity_harvest.py index 392dece9..5a7589c8 100644 --- a/backend/app/workers/tasks/opportunity_harvest.py +++ b/backend/app/workers/tasks/opportunity_harvest.py @@ -95,10 +95,14 @@ def harvest_opportunity_overlays(quarters: list[str] | None = None) -> dict[str, fetched_at=fetched_at, ): n_features += 1 + # Durable per-quarter commit: длинный grid-walk не теряет прогресс при + # краше/таймауте середины прогона (commit раз-в-конце терял ВСЁ). + db.commit() except Exception as exc: logger.warning("opportunity_harvest: квартал %s failed: %s", quarter, exc) + db.rollback() # сбросить незакоммиченный tx квартала перед следующим continue - db.commit() + db.commit() # финальный — на случай пустого списка / подстраховка logger.info("opportunity_harvest: готово, кварталов=%d фич=%d", n_quarters, n_features) return {"quarters": n_quarters, "features": n_features} finally: diff --git a/backend/tests/workers/test_ird_harvest.py b/backend/tests/workers/test_ird_harvest.py index 1d075055..bc7c6e64 100644 --- a/backend/tests/workers/test_ird_harvest.py +++ b/backend/tests/workers/test_ird_harvest.py @@ -55,6 +55,8 @@ class _FakeDB: def __init__(self) -> None: self.executed: list[tuple[Any, dict[str, Any] | None]] = [] self.committed = False + self.commit_count = 0 + self.rollback_count = 0 self.closed = False @contextmanager @@ -67,6 +69,10 @@ class _FakeDB: def commit(self) -> None: self.committed = True + self.commit_count += 1 + + def rollback(self) -> None: + self.rollback_count += 1 def close(self) -> None: self.closed = True @@ -131,3 +137,35 @@ def test_upsert_feature_skips_missing_geom_data_id(monkeypatch: Any) -> None: is False ) 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