gendesign/backend/tests/workers/test_2464_objective_zombie_sweep.py
bot-backend cdf493f345
All checks were successful
Deploy / changes (push) Successful in 9s
Deploy Trade-In / changes (push) Successful in 13s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy / build-backend (push) Successful in 2m23s
Deploy Trade-In / test (push) Successful in 3m56s
Deploy / build-worker (push) Successful in 4m16s
Deploy Trade-In / build-backend (push) Successful in 1m19s
Deploy / deploy (push) Successful in 1m49s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 12s
Deploy Trade-In / deploy (push) Successful in 2m25s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
chore(format): нормализация под ruff 0.15.20 — 161 файл, только формат (#2864) (#3022)
2026-08-21 12:01:52 +00:00

135 lines
5.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""На worker_ready осиротевшие прогоны Объектива закрываются, а не висят вечно (#2464).
`objective_scrape_runs` не подметалась ничем: `worker_ready` знал только про
`kn_scrape_runs` и `nspd_geo_jobs`. На проде 2026-08-20 в ней висело **6 строк
'running' с 17.05** (94 суток) при 71 'done' и **ни одном 'failed'** — след
отравления сессии, из-за которого `_finish_run(status='failed')` не мог
записаться (причина починена #2972). Причина устранена, но жёсткое убийство
воркера по-прежнему оставляет 'running' навсегда.
Тесты смотрят на выполненный SQL — то есть на поведение функции, а не на её текст.
На origin/main запроса к `objective_scrape_runs` нет вовсе, поэтому головной тест
там красный по существу («строка не закрыта»), а не из-за отсутствующего символа.
"""
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 MagicMock, patch
class _Result:
def __init__(self, rows: list[dict]) -> None:
self._rows = rows
def mappings(self) -> _Result:
return self
def all(self) -> list[dict]:
return self._rows
def scalar(self) -> Any:
return None
def first(self) -> Any:
return None
class _Session:
def __init__(self) -> None:
self.sql: list[str] = []
def execute(self, statement: Any = None, params: Any = None, *a: Any, **kw: Any) -> _Result:
text_ = str(statement)
self.sql.append(text_)
if "objective_scrape_runs" in text_ and "RETURNING" in text_:
return _Result([{"run_id": 1}, {"run_id": 2}])
return _Result([])
def commit(self) -> None:
pass
def rollback(self) -> None:
pass
def close(self) -> None:
pass
def _run() -> _Session:
from app.workers import lifecycle as mod
db = _Session()
import app.core.db as core_db
with (
patch.object(core_db, "SessionLocal", lambda: db),
patch.dict(
"sys.modules",
{
"app.workers.tasks.scrape_kn": MagicMock(),
"app.workers.tasks.nspd_geo": MagicMock(_ZOMBIE_PAUSED_THRESHOLD="30 minutes"),
},
),
):
mod._resume_zombie_runs()
return db
def _objective_update(db: _Session) -> str | None:
for sql in db.sql:
if "UPDATE objective_scrape_runs" in sql and "zombie" in sql:
return sql
return None
def test_orphan_objective_run_is_closed() -> None:
"""Головной: worker_ready обязан закрыть 'running'-прогоны Объектива.
На origin/main такого запроса нет вовсе — строки остаются 'running' навсегда.
"""
db = _run()
assert _objective_update(db) is not None, (
"worker_ready не трогает objective_scrape_runs — осиротевший прогон "
f"останется 'running' навсегда; выполнено запросов: {len(db.sql)}"
)
def test_only_running_rows_are_touched() -> None:
"""Контроль от переусердствования: закрываем только 'running'.
Без этого условия подметание переписало бы 'done'-прогоны и стёрло историю.
"""
sql = _objective_update(db := _run())
assert sql is not None
assert "WHERE status = 'running'" in sql, f"нет сужения по статусу:\n{sql}"
assert "'done'" not in sql, f"запрос упоминает 'done' — риск задеть завершённые:\n{sql}"
assert db is not None
def test_finished_at_is_last_sign_of_life_not_now() -> None:
"""Контроль честности времени: прогон, умерший 94 дня назад, не «завершён сейчас».
NOW() здесь соврал бы и оператору в списке прогонов, и любому будущему
потребителю finished_at.
"""
sql = _objective_update(_run())
assert sql is not None
assert "COALESCE(heartbeat_at, started_at)" in sql, (
f"finished_at ставится не по последнему признаку жизни:\n{sql}"
)
assert "finished_at = NOW()" not in sql, (
f"finished_at = NOW() — время завершения соврано:\n{sql}"
)
def test_kn_sweep_still_runs() -> None:
"""Контроль от регресса: добавление Объектива не сломало подметание kn."""
db = _run()
assert any("kn_scrape_runs" in s for s in db.sql), (
f"подметание kn_scrape_runs пропало; выполнено: {db.sql}"
)