gendesign/backend/tests/workers/test_2464_objective_zombie_sweep.py
bot-backend 14818f0303
All checks were successful
CI Trade-In / changes (pull_request) Successful in 15s
CI / changes (pull_request) Successful in 15s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m56s
CI Trade-In / backend-tests (pull_request) Successful in 5m25s
CI / backend-tests (pull_request) Successful in 18m18s
chore(format): нормализация под ruff 0.15.20 — 161 файл, только формат (#2864)
Почему: файлы в main были отформатированы старым ruff (хук 0.7.4), а после
#3021 хук, CI и `uv run ruff format` — на 0.15.20. Без нормализации каждый
коммит, касающийся одного из этих 161 файлов, тащил бы посторонние хунки
на нетронутых строках (длинные assert-сообщения и т.п.).

Что: `ruff format` 0.15.20 по backend/ и tradein-mvp/backend/; ничего,
кроме форматирования (958+/1034−). `ruff check` 0.15.20 зелёный на обоих
проектах, повторный `format --check` — 1001 файл без изменений
(идемпотентно), compileall чистый.

Refs #2864
2026-08-21 16:39:28 +05: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}"
)