From cfa0046b34164fb1105df3f27142fe32a257bf17 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 20 Aug 2026 11:04:07 +0000 Subject: [PATCH] =?UTF-8?q?fix(ptica):=20=D1=83=D0=BF=D0=B0=D0=B2=D1=88?= =?UTF-8?q?=D0=B8=D0=B9=20=D0=BF=D1=80=D0=BE=D0=B3=D0=BE=D0=BD=20=D0=9E?= =?UTF-8?q?=D0=B1=D1=8A=D0=B5=D0=BA=D1=82=D0=B8=D0=B2=D0=B0=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=BC=D0=B5=D1=87=D0=B0=D0=B5=D1=82=D1=81=D1=8F=20failed,=20?= =?UTF-8?q?=D0=B0=20=D0=BD=D0=B5=20=D0=B2=D0=B8=D1=81=D0=B8=D1=82=20runnin?= =?UTF-8?q?g=20=D0=B2=D0=B5=D1=87=D0=BD=D0=BE=20(#2464)=20(#2972)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/workers/tasks/scrape_objective.py | 23 ++- .../test_2464_objective_run_not_stuck.py | 136 ++++++++++++++++++ 2 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 backend/tests/workers/test_2464_objective_run_not_stuck.py diff --git a/backend/app/workers/tasks/scrape_objective.py b/backend/app/workers/tasks/scrape_objective.py index 1cfa2fca..cfbb0ca8 100644 --- a/backend/app/workers/tasks/scrape_objective.py +++ b/backend/app/workers/tasks/scrape_objective.py @@ -402,10 +402,31 @@ def sync_objective_group( } except Exception as e: if run_id: + # #2464: сессия здесь МОЖЕТ быть отравлена. Исходный сбой бывает + # DB-level (напр. INSERT в _save_raw), и тогда транзакция остаётся в + # aborted-состоянии: следующий execute падает, _finish_run не проходит, + # а голый `except Exception: pass` ниже гасил это молча — строка прогона + # навсегда оставалась в status='running'. Замер прода 20.08: шесть таких + # строк висят с 17.05, то есть 95 суток; уборщика зомби для + # objective_scrape_runs нет. + # + # Сессия здесь СВОЯ (SessionLocal() выше, close в finally), поэтому + # плоский rollback законен: он отбрасывает уже провалившуюся транзакцию + # и ничего чужого не теряет. + try: + db.rollback() + except Exception: + logger.exception("sync_objective_group: rollback перед _finish_run не удался") try: _finish_run(db, run_id, status="failed", error=f"{type(e).__name__}: {e}") except Exception: - pass + # Больше не молча: если и это не прошло, строка останется 'running', + # и знать об этом важнее, чем сохранить тишину в логе. + logger.exception( + "sync_objective_group: не удалось пометить run_id=%s как failed —" + " строка останется в status='running'", + run_id, + ) raise finally: db.close() diff --git a/backend/tests/workers/test_2464_objective_run_not_stuck.py b/backend/tests/workers/test_2464_objective_run_not_stuck.py new file mode 100644 index 00000000..f90048a1 --- /dev/null +++ b/backend/tests/workers/test_2464_objective_run_not_stuck.py @@ -0,0 +1,136 @@ +"""Упавший прогон Объектива помечается failed, а не остаётся running навсегда (#2464). + +Обработчик в `sync_objective_group` выглядел так: + + except Exception as e: + if run_id: + try: + _finish_run(db, run_id, status="failed", ...) + except Exception: + pass # ← молча + +Если исходный сбой был DB-level (напр. INSERT в `_save_raw`), транзакция остаётся в +aborted-состоянии: `_finish_run` падает уже на своём execute, это гасится голым `pass`, +и строка прогона навсегда остаётся в `status='running'`. + +Замер прода 20.08.2026: шесть таких строк висят с 17.05 — 2274 часа, 95 суток. Уборщика +зомби для `objective_scrape_runs` нет (в отличие от cadastre). + +Проверяется на `PostgresLikeSession` — двойнике с настоящей семантикой aborted-транзакции. +На `MagicMock` тест был бы зелёным по построению: у него нет aborted-состояния, и любой +следующий execute «успешен». +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from typing import Any +from unittest.mock import patch + +import pytest + +from tests.support.pg_session import PostgresLikeSession + +_RUN_ID = 42 + + +class _Session(PostgresLikeSession): + """Двойник + close() и журнал УСПЕШНО выполненного SQL.""" + + def __init__(self, **kw: Any) -> None: + super().__init__(**kw) + self.sql: list[str] = [] + self.closed = False + + def execute(self, statement: Any = None, *a: Any, **kw: Any): # type: ignore[no-untyped-def] + res = super().execute(statement, *a, **kw) + self.sql.append(str(statement)) + return res + + def close(self) -> None: + self.closed = True + + +def _run_with_poisoned_session() -> _Session: + """Прогон, где _start_url травит сессию, а тело падает следом.""" + from app.workers.tasks import scrape_objective as mod + + db = _Session(fail_on=(1,)) + + def _fake_start(session: Any, *_a: Any, **_kw: Any) -> int: + # Первый же execute падает → транзакция aborted (как DB-сбой в _save_raw). + try: + session.execute("INSERT INTO objective_scrape_runs ...") + except RuntimeError: + pass + return _RUN_ID + + class _BoomClient: + def __init__(self, *_a: Any, **_kw: Any) -> None: + raise RuntimeError("сбой сразу после старта прогона") + + with ( + # Без ключа функция выходит на первой строке — подменяем, иначе тест + # проверял бы ранний return, а не обработчик ошибки. + patch.object(mod.settings, "objective_api_key", "test-key"), + patch.object(mod, "SessionLocal", lambda: db), + patch.object(mod, "_start_run", _fake_start), + patch.object(mod, "ObjectiveClient", _BoomClient), + pytest.raises(RuntimeError), + ): + mod.sync_objective_group(group_name="test", triggered_by="unit") + + return db + + +def _marked_failed(db: _Session) -> bool: + return any("objective_scrape_runs" in s and "status" in s for s in db.sql) + + +def test_failed_run_is_marked_even_on_poisoned_session() -> None: + """Прогон обязан получить status='failed' даже когда транзакция отравлена. + + На origin/main этого UPDATE в журнале нет: execute падает на aborted-сессии, + и голый `except Exception: pass` гасит отказ. + """ + db = _run_with_poisoned_session() + + assert _marked_failed(db), ( + "UPDATE статуса прогона не выполнился — строка осталась в status='running' " + f"навсегда. Выполненный SQL: {db.sql}" + ) + + +def test_session_is_closed_anyway() -> None: + """Контроль: сессия закрывается в finally независимо от исхода.""" + db = _run_with_poisoned_session() + assert db.closed, "сессия не закрыта — утечка соединения на аварийном пути" + + +def test_original_error_still_propagates() -> None: + """Контроль: исходная ошибка не проглатывается — она и есть причина падения. + + Ловит «починку», которая заодно погасила бы исключение: тогда Celery считал бы + прогон успешным. + """ + from app.workers.tasks import scrape_objective as mod + + db = _Session(fail_on=()) + + class _BoomClient: + def __init__(self, *_a: Any, **_kw: Any) -> None: + raise RuntimeError("характерный текст ошибки") + + with ( + patch.object(mod.settings, "objective_api_key", "test-key"), + patch.object(mod, "SessionLocal", lambda: db), + patch.object(mod, "_start_run", lambda *_a, **_kw: _RUN_ID), + patch.object(mod, "ObjectiveClient", _BoomClient), + pytest.raises(RuntimeError, match="характерный текст ошибки"), + ): + mod.sync_objective_group(group_name="test", triggered_by="unit") + + assert _marked_failed(db), "на ЗДОРОВОЙ сессии пометка тем более обязана пройти"