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
Почему: файлы в 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
135 lines
5.1 KiB
Python
135 lines
5.1 KiB
Python
"""На 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}"
|
||
)
|