Compare commits
1 commit
main
...
fix/2464-o
| Author | SHA1 | Date | |
|---|---|---|---|
| 7bc26ca260 |
2 changed files with 158 additions and 1 deletions
|
|
@ -402,10 +402,31 @@ def sync_objective_group(
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if run_id:
|
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:
|
try:
|
||||||
_finish_run(db, run_id, status="failed", error=f"{type(e).__name__}: {e}")
|
_finish_run(db, run_id, status="failed", error=f"{type(e).__name__}: {e}")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
# Больше не молча: если и это не прошло, строка останется 'running',
|
||||||
|
# и знать об этом важнее, чем сохранить тишину в логе.
|
||||||
|
logger.exception(
|
||||||
|
"sync_objective_group: не удалось пометить run_id=%s как failed —"
|
||||||
|
" строка останется в status='running'",
|
||||||
|
run_id,
|
||||||
|
)
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
|
||||||
136
backend/tests/workers/test_2464_objective_run_not_stuck.py
Normal file
136
backend/tests/workers/test_2464_objective_run_not_stuck.py
Normal file
|
|
@ -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), "на ЗДОРОВОЙ сессии пометка тем более обязана пройти"
|
||||||
Loading…
Add table
Reference in a new issue