arm-core/api/tests/test_recognition_exchange.py
Andrey Epifancev c9d60907d3 Повторная публикация обновляет request_subject; тексты без умолчаний как фактических subject'ов (I-1, M-1, M-3, M-5, #106)
I-1: ветка повторной публикации в _process_one записывает request_subject
(как первая публикация); тест test_republish_updates_request_subject.
M-1: примеры /healthcheck в README (ядро: три subject'а; tabular-parser: два).
M-3: docstring'и и логи не выдают умолчание за фактический subject.
M-5: CLAUDE.md — subject'ы задаются OPENBAO__NATS__NATS_*_SUBJECT.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:28:07 +04:00

894 lines
38 KiB
Python
Raw Permalink 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.

"""Outbox-публикатор и вахтёр сроков `recognition_job` (spec recognition-job-exchange: «Outbox публикует задания из
`queued`», «Запрос без ack публикуется повторно, затем — `dlq`», «Таймаут ожидания результата»; design D-6). Срез 2c:
`timeout` вахтёра и `dlq` публикатора переводят файл текущего задания в `ERROR` (spec primary-files «Жизненный цикл
статуса файла», design D-5).
Интеграционные тесты работают против реального PostgreSQL (БД сессии pytest) и NATS из `.env.test`. Публикатор и
вахтёр ведут собственные транзакции, поэтому данные фиксируются реально (`CommittedJobs`) и удаляются в teardown.
Subject'ы запросов общие (`files.*_uploaded`): тестовый подписчик фильтрует сообщения по `job_id` своих заданий.
Время в тестах повторов — инжектируемые часы `now`, без реальных ожиданий таймаутов.
"""
import asyncio
import json
import time
import uuid
from collections.abc import AsyncIterator, Callable
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any, get_args
import pytest
import pytest_asyncio
from jsonschema import Draft202012Validator, FormatChecker
from nats.aio.client import Client as NATS
from nats.aio.msg import Msg
from sqlalchemy import JSON, update
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
from src.contract import NATS_MSG_ID_HEADER, RecognitionRequest
from src.contract.request import FileType as ContractFileType
from src.core.config import get_settings
from src.core.nats import NatsConnection, NatsPublishError
from src.models.enums import FileStatus, FileType, RecognitionPipeline, RecognitionStatus
from src.models.files import RecognitionJob
from src.services import recognition_exchange as rx
from src.services.recognition_exchange import (
WATCHDOG_PERIOD_SECONDS,
ExchangeTasks,
expire_deadlines,
publish_pending,
run_publisher_loop,
run_watchdog_loop,
)
from tests.factories import CommittedJobs
CONTRACTS_DIR = Path(__file__).resolve().parents[1] / "contracts"
REQUEST_SCHEMA = json.loads((CONTRACTS_DIR / "recognition-request.schema.json").read_text(encoding="utf-8"))
REQUEST_VALIDATOR = Draft202012Validator(REQUEST_SCHEMA, format_checker=FormatChecker())
REQUEST_SUBJECTS = ("files.tabular_uploaded", "files.img_uploaded")
ACK = timedelta(seconds=30)
MAX_REPUBLISH = 3
READY_TIMEOUT = 5.0
CONCURRENT_JOBS = 20
MIN_LOOP_PASSES = 3 # цикл пережил несколько ошибок прохода подряд
EXCHANGE_TASKS = 2 # публикатор и вахтёр
# --- инфраструктура ---------------------------------------------------------------------------------------------
class Clock:
"""Инжектируемые часы: `now` для публикатора и вахтёра, `advance` — сдвиг без реального ожидания."""
def __init__(self) -> None:
self.current = datetime.now(UTC)
def __call__(self) -> datetime:
return self.current
def advance(self, delta: timedelta) -> None:
self.current += delta
class RequestSubscriber:
"""Тестовый подписчик на subject'ы запросов: «голый» клиент nats-py, сообщения фильтруются по `job_id`."""
def __init__(self, subjects: tuple[str, ...] = REQUEST_SUBJECTS) -> None:
self.subjects = subjects
self.messages: list[Msg] = []
self._nc = NATS()
async def start(self) -> None:
url = get_settings().OPENBAO.NATS.NATS_URL
try:
await self._nc.connect(url, connect_timeout=2, allow_reconnect=False)
except Exception as exc:
pytest.fail(f"NATS недоступен ({url}, .env.test): {type(exc).__name__}", pytrace=False)
async def collect(msg: Msg) -> None:
self.messages.append(msg)
for subject in self.subjects:
await self._nc.subscribe(subject, cb=collect)
await self._nc.flush()
async def stop(self) -> None:
await self._nc.close()
def for_job(self, job_id: uuid.UUID) -> list[Msg]:
return [m for m in self.messages if json.loads(m.data).get("job_id") == str(job_id)]
async def settle(self) -> None:
"""Всё, что сервер принял до этого момента, доставлено подписчику (PING/PONG после сообщений) и обработано."""
await self._nc.flush()
await asyncio.sleep(0.1)
async def wait_for(self, job_id: uuid.UUID, count: int, timeout: float = 10.0) -> list[Msg]:
deadline = time.monotonic() + timeout
while len(self.for_job(job_id)) < count:
if time.monotonic() > deadline:
raise AssertionError(f"за {timeout} с получено {len(self.for_job(job_id))} из {count} сообщений")
await asyncio.sleep(0.02)
return self.for_job(job_id)
class FakeNats:
"""Подставной NATS: состояние соединения и счётчик публикаций; первые `fail_first` публикаций — ошибка."""
def __init__(self, *, connected: bool = True, fail_first: int = 0, delegate: Any = None, delay: float = 0) -> None:
self.is_connected_value = connected
self.fail_first = fail_first
self.delegate = delegate
self.delay = delay
self.calls: list[tuple[str, str]] = []
@property
def is_connected(self) -> bool:
return self.is_connected_value
async def publish(self, subject: str, payload: bytes, msg_id: str) -> None:
self.calls.append((subject, msg_id))
if len(self.calls) <= self.fail_first:
raise NatsPublishError(f"подставная ошибка публикации {msg_id}")
if self.delay:
await asyncio.sleep(self.delay)
if self.delegate is not None:
await self.delegate.publish(subject, payload, msg_id)
@pytest.fixture
def session_factory(db_engine: AsyncEngine) -> async_sessionmaker[AsyncSession]:
return async_sessionmaker(db_engine, expire_on_commit=False)
@pytest_asyncio.fixture
async def jobs(session_factory: async_sessionmaker[AsyncSession]) -> AsyncIterator[CommittedJobs]:
committed = CommittedJobs(session_factory)
try:
yield committed
finally:
await committed.cleanup()
@pytest_asyncio.fixture
async def nats() -> AsyncIterator[NatsConnection]:
conn = NatsConnection(get_settings().OPENBAO.NATS)
await conn.start()
try:
if not await conn.wait_ready(timeout=READY_TIMEOUT):
pytest.fail(f"NATS недоступен ({get_settings().OPENBAO.NATS.NATS_URL}, .env.test)", pytrace=False)
yield conn
finally:
await conn.stop()
@pytest_asyncio.fixture
async def subscriber() -> AsyncIterator[RequestSubscriber]:
sub = RequestSubscriber()
await sub.start()
try:
yield sub
finally:
await sub.stop()
async def _set_status(
session_factory: async_sessionmaker[AsyncSession], job_id: uuid.UUID, status: RecognitionStatus
) -> None:
async with session_factory() as session, session.begin():
await session.execute(update(RecognitionJob).where(RecognitionJob.id == job_id).values(status=status))
def _publish(
session_factory: async_sessionmaker[AsyncSession], nats: Any, now: Callable[[], datetime] | None = None
) -> Any:
return publish_pending(session_factory, nats, ack_timeout=ACK, max_republish=MAX_REPUBLISH, now=now)
# --- юнит: соответствие enum, константы ------------------------------------------------------------------------
def test_orm_file_type_matches_contract_enum():
"""`stored_file.file_type` (DBML) и `file_type` схемы запроса — одно множество значений."""
assert {m.value for m in FileType} == set(get_args(ContractFileType))
assert set(REQUEST_SCHEMA["properties"]["file_type"]["enum"]) == {m.value for m in FileType}
def test_orm_pipeline_matches_request_subjects():
assert {m.value for m in RecognitionPipeline} == {"tabular", "image"}
def test_watchdog_period_is_30_seconds():
assert WATCHDOG_PERIOD_SECONDS == 30 # noqa: PLR2004 - значение из spec
# --- публикация -------------------------------------------------------------------------------------------------
@pytest.mark.integration
@pytest.mark.parametrize(
("pipeline", "file_type", "subject"),
[
(RecognitionPipeline.tabular, FileType.XLSX, "files.tabular_uploaded"),
(RecognitionPipeline.image, FileType.PDF, "files.img_uploaded"),
],
)
async def test_publish_request_to_service( # noqa: PLR0913,PLR0917 - фикстуры и параметры
session_factory: async_sessionmaker[AsyncSession],
jobs: CommittedJobs,
nats: NatsConnection,
subscriber: RequestSubscriber,
pipeline: RecognitionPipeline,
file_type: FileType,
subject: str,
):
"""Сценарий «Публикация запроса парсеру»: сообщение по схеме, `attempt = 1`, строка `published`."""
job_id = await jobs.make_queued_job(pipeline=pipeline, file_type=file_type, original_name="ОСВ 60.xlsx")
before = datetime.now(UTC)
report = await _publish(session_factory, nats)
assert report.published == 1
(msg,) = await subscriber.wait_for(job_id, 1)
body = json.loads(msg.data)
errors = [e.message for e in REQUEST_VALIDATOR.iter_errors(body)]
assert errors == []
request = RecognitionRequest.model_validate_json(msg.data)
job = await jobs.get(job_id)
assert msg.subject == subject
assert msg.headers == {NATS_MSG_ID_HEADER: f"{job_id}:1"}
assert request.job_id == job_id
assert request.file_id == job.file_id
assert request.pipeline == pipeline.value
assert request.file_type == file_type.value
assert request.original_name == "ОСВ 60.xlsx"
assert request.s3.bucket == "arm-ka"
assert request.s3.key == await jobs.s3_key(job_id)
assert request.attempt == 1
assert request.schema_version == "1.0"
# reply_deadline не был задан: вычислен при первой публикации (парсер — 5 мин, OCR — 20 мин) и сохранён в задание
assert job.reply_deadline is not None
assert request.reply_deadline == job.reply_deadline
expected = timedelta(minutes=20 if pipeline is RecognitionPipeline.image else 5)
assert expected - timedelta(minutes=1) < job.reply_deadline - before < expected + timedelta(minutes=1)
assert job.status is RecognitionStatus.published
assert job.request_msg_id == f"{job_id}:1"
assert job.request_subject == subject
assert job.published_at is not None and job.published_at >= before
assert job.requested_at is not None
assert job.attempt == 1
@pytest.mark.integration
async def test_publish_uses_configured_request_subject(
session_factory: async_sessionmaker[AsyncSession], jobs: CommittedJobs, nats: NatsConnection
) -> None:
"""Review Focus 4: subject — из настроек окружения, `request_subject` задания — фактический subject публикации."""
run = uuid.uuid4().hex[:8]
subjects = {"tabular": f"test-{run}.files.tabular_uploaded", "image": f"test-{run}.files.img_uploaded"}
custom = RequestSubscriber(subjects=tuple(subjects.values()))
await custom.start()
try:
job_id = await jobs.make_queued_job(
pipeline=RecognitionPipeline.tabular, file_type=FileType.XLSX, original_name="ОСВ 60.xlsx"
)
await publish_pending(
session_factory, nats, ack_timeout=ACK, max_republish=MAX_REPUBLISH, request_subjects=subjects
)
(msg,) = await custom.wait_for(job_id, 1)
finally:
await custom.stop()
assert msg.subject == subjects["tabular"]
assert (await jobs.get(job_id)).request_subject == subjects["tabular"]
@pytest.mark.integration
async def test_republish_updates_request_subject(
session_factory: async_sessionmaker[AsyncSession], jobs: CommittedJobs, nats: NatsConnection
) -> None:
"""I-1: повторная публикация уходит на актуальный subject и обновляет `request_subject` задания."""
run = uuid.uuid4().hex[:8]
subjects = {"tabular": f"test-{run}.files.tabular_uploaded", "image": f"test-{run}.files.img_uploaded"}
clock = Clock()
job_id = await jobs.make_queued_job(
pipeline=RecognitionPipeline.tabular,
file_type=FileType.XLSX,
original_name="ОСВ 61.xlsx",
reply_deadline=clock() + timedelta(hours=1),
)
assert (await _publish(session_factory, nats, clock)).published == 1
old_subject = (await jobs.get(job_id)).request_subject
assert old_subject != subjects["tabular"]
custom = RequestSubscriber(subjects=tuple(subjects.values()))
await custom.start()
try:
clock.advance(ACK + timedelta(seconds=1))
report = await publish_pending(
session_factory,
nats,
ack_timeout=ACK,
max_republish=MAX_REPUBLISH,
now=clock,
request_subjects=subjects,
)
assert report.republished == 1
(msg,) = await custom.wait_for(job_id, 1)
finally:
await custom.stop()
assert msg.subject == subjects["tabular"]
assert (await jobs.get(job_id)).request_subject == subjects["tabular"]
@pytest.mark.integration
async def test_publish_keeps_existing_reply_deadline(
session_factory: async_sessionmaker[AsyncSession],
jobs: CommittedJobs,
nats: NatsConnection,
subscriber: RequestSubscriber,
):
deadline = datetime.now(UTC).replace(microsecond=0) + timedelta(hours=2)
job_id = await jobs.make_queued_job(reply_deadline=deadline)
await _publish(session_factory, nats)
(msg,) = await subscriber.wait_for(job_id, 1)
assert RecognitionRequest.model_validate_json(msg.data).reply_deadline == deadline
assert (await jobs.get(job_id)).reply_deadline == deadline
@pytest.mark.integration
async def test_first_publication_msg_id_follows_attempt(
session_factory: async_sessionmaker[AsyncSession],
jobs: CommittedJobs,
nats: NatsConnection,
subscriber: RequestSubscriber,
):
"""`queued` с `attempt = 2` и устаревшим `request_msg_id` (будущий replay): id и тело — по текущему attempt."""
job_id = await jobs.make_queued_job(attempt=2, request_msg_id=f"stale-{uuid.uuid4()}")
await _publish(session_factory, nats)
(msg,) = await subscriber.wait_for(job_id, 1)
assert msg.headers == {NATS_MSG_ID_HEADER: f"{job_id}:2"}
assert RecognitionRequest.model_validate_json(msg.data).attempt == 2 # noqa: PLR2004 - attempt задания
job = await jobs.get(job_id)
assert (job.request_msg_id, job.attempt) == (f"{job_id}:2", 2)
@pytest.mark.integration
@pytest.mark.parametrize("current", [True, False], ids=["current", "non_current"])
async def test_invalid_request_marks_job_error_and_pass_continues(
session_factory: async_sessionmaker[AsyncSession],
jobs: CommittedJobs,
nats: NatsConnection,
subscriber: RequestSubscriber,
current: bool,
):
"""Запрос не собирается по схеме (нет `original_name`) — задание `error/INVALID_REQUEST`, проход идёт дальше; файл
текущего задания — `ERROR` (финальная ошибка задания, spec primary-files), файл чужого задания не меняется."""
clock = Clock()
file_id, broken = await jobs.make_file_job(original_name=None)
# новое задание — `processing`, чтобы публикатор его не трогал (в проходе только старое задание файла)
new_job = None if current else await jobs.add_job(file_id, status=RecognitionStatus.processing, current=True)
good = await jobs.make_queued_job()
report = await _publish(session_factory, nats, clock)
assert (report.invalid, report.published) == (1, 1)
await subscriber.wait_for(good, 1)
await subscriber.settle()
assert subscriber.for_job(broken) == []
job = await jobs.get(broken)
assert job.status is RecognitionStatus.error
assert job.error_code == "INVALID_REQUEST"
assert job.error_message and "original_name" in job.error_message
assert job.finished_at is not None
assert job.request_msg_id is None
file = await jobs.file(file_id)
if current:
assert (file.status, file.updated_at) == (FileStatus.ERROR, clock())
else:
assert (file.status, file.current_recognition_job_id, file.updated_at) == (FileStatus.UPLOADED, new_job, None)
# --- повторы без ack и dlq ---------------------------------------------------------------------------------------
@pytest.mark.integration
async def test_republish_without_ack_then_dlq(
session_factory: async_sessionmaker[AsyncSession],
jobs: CommittedJobs,
nats: NatsConnection,
subscriber: RequestSubscriber,
):
"""Сценарий «Сервис недоступен»: 1 + 3 публикации с одним `Nats-Msg-Id`, затем `dlq / NO_ACK`."""
clock = Clock()
job_id = await jobs.make_queued_job(reply_deadline=clock() + timedelta(hours=1), metrics={"note": "сохранить"})
assert (await _publish(session_factory, nats, clock)).published == 1
first = await jobs.get(job_id)
# раньше ACK_TIMEOUT повтора нет
clock.advance(ACK - timedelta(seconds=1))
assert (await _publish(session_factory, nats, clock)).republished == 0
for n in range(1, MAX_REPUBLISH + 1):
clock.advance(timedelta(seconds=2)) # с последней публикации прошло больше ACK_TIMEOUT
report = await _publish(session_factory, nats, clock)
assert report.republished == 1
job = await jobs.get(job_id)
assert job.metrics == {"note": "сохранить", "republish_count": n}
assert job.published_at == clock()
assert job.status is RecognitionStatus.published
clock.advance(ACK - timedelta(seconds=1))
messages = await subscriber.wait_for(job_id, 1 + MAX_REPUBLISH)
assert {m.headers[NATS_MSG_ID_HEADER] for m in messages} == {f"{job_id}:1"}
assert len({m.data for m in messages}) == 1 # то же тело: тот же attempt и reply_deadline
clock.advance(timedelta(seconds=2))
report = await _publish(session_factory, nats, clock)
assert (report.dlq, report.republished, report.published) == (1, 0, 0)
job = await jobs.get(job_id)
assert job.status is RecognitionStatus.dlq
assert job.error_code == "NO_ACK"
assert job.error_message
assert job.finished_at == clock()
assert job.request_msg_id == first.request_msg_id == f"{job_id}:1"
assert job.attempt == 1
assert job.metrics == {"note": "сохранить", "republish_count": MAX_REPUBLISH}
clock.advance(ACK * 2)
assert (await _publish(session_factory, nats, clock)).total == 0
await subscriber.settle()
assert len(subscriber.for_job(job_id)) == 1 + MAX_REPUBLISH
@pytest.mark.integration
@pytest.mark.parametrize(
("stored", "expected_state"),
[
(None, (True, None)), # Python None → SQL NULL (JSONB(none_as_null=True), финальное ревью I-1)
(JSON.NULL, (False, "null")), # jsonb null: строки, записанные до исправления или сырым SQL
],
ids=["sql_null", "json_null"],
)
async def test_republish_counter_on_null_metrics(
session_factory: async_sessionmaker[AsyncSession], jobs: CommittedJobs, stored: Any, expected_state: Any
):
"""Обход для не-объекта в `metrics` (`jsonb_typeof` CASE) работает и для SQL NULL, и для jsonb null."""
clock = Clock()
job_id = await jobs.make_queued_job(reply_deadline=clock() + timedelta(hours=1), metrics=stored)
assert await jobs.metrics_sql_state(job_id) == expected_state
fake = FakeNats()
assert (await _publish(session_factory, fake, clock)).published == 1
assert await jobs.metrics_sql_state(job_id) == expected_state # первая публикация metrics не трогает
clock.advance(ACK + timedelta(seconds=1))
assert (await _publish(session_factory, fake, clock)).republished == 1
assert (await jobs.get(job_id)).metrics == {"republish_count": 1}
assert await jobs.metrics_sql_state(job_id) == (False, "object")
@pytest.mark.integration
async def test_ack_after_first_republish_stops_republishing(
session_factory: async_sessionmaker[AsyncSession],
jobs: CommittedJobs,
nats: NatsConnection,
subscriber: RequestSubscriber,
):
"""Сценарий «Сервис появился после первого повтора»: после ack (`processing`) повторов нет."""
clock = Clock()
job_id = await jobs.make_queued_job(reply_deadline=clock() + timedelta(hours=1))
await _publish(session_factory, nats, clock)
clock.advance(ACK + timedelta(seconds=1))
assert (await _publish(session_factory, nats, clock)).republished == 1
await subscriber.wait_for(job_id, 2)
await _set_status(session_factory, job_id, RecognitionStatus.processing) # имитация ack
for _ in range(MAX_REPUBLISH + 1):
clock.advance(ACK + timedelta(seconds=1))
assert (await _publish(session_factory, nats, clock)).total == 0
await subscriber.settle()
assert len(subscriber.for_job(job_id)) == 2 # noqa: PLR2004 - первая публикация и один повтор
job = await jobs.get(job_id)
assert job.status is RecognitionStatus.processing
assert job.metrics == {"republish_count": 1}
@pytest.mark.integration
async def test_publisher_skips_published_job_with_passed_deadline(
session_factory: async_sessionmaker[AsyncSession],
jobs: CommittedJobs,
nats: NatsConnection,
subscriber: RequestSubscriber,
):
"""`published` с прошедшим `reply_deadline` не републикуется и не уходит в dlq — его забирает вахтёр."""
now = datetime.now(UTC)
job_id = await jobs.make_queued_job(
status=RecognitionStatus.published,
published_at=now - timedelta(hours=1),
reply_deadline=now - timedelta(minutes=1),
request_msg_id=f"{uuid.uuid4()}:1",
metrics={"republish_count": MAX_REPUBLISH},
)
assert (await _publish(session_factory, nats)).total == 0
await subscriber.settle()
assert subscriber.for_job(job_id) == []
assert (await jobs.get(job_id)).status is RecognitionStatus.published
assert await expire_deadlines(session_factory) == 1
job = await jobs.get(job_id)
assert (job.status, job.error_code) == (RecognitionStatus.timeout, "TIMEOUT")
# --- NATS недоступен ---------------------------------------------------------------------------------------------
@pytest.mark.integration
async def test_pass_skipped_when_nats_disconnected(
session_factory: async_sessionmaker[AsyncSession], jobs: CommittedJobs
):
job_id = await jobs.make_queued_job()
fake = FakeNats(connected=False)
report = await _publish(session_factory, fake)
assert report.skipped
assert fake.calls == []
assert (await jobs.get(job_id)).status is RecognitionStatus.queued
@pytest.mark.integration
async def test_publish_error_keeps_queued_and_stops_pass(
session_factory: async_sessionmaker[AsyncSession],
jobs: CommittedJobs,
nats: NatsConnection,
subscriber: RequestSubscriber,
):
"""Сценарий «NATS недоступен»: задание остаётся `queued`, проход прерван; следующий проход публикует."""
first = await jobs.make_queued_job()
second = await jobs.make_queued_job()
fake = FakeNats(fail_first=1)
report = await _publish(session_factory, fake)
assert (report.failed, report.published) == (1, 0)
assert len(fake.calls) == 1 # второе задание в этом проходе не пытались публиковать
for job_id in (first, second):
job = await jobs.get(job_id)
assert job.status is RecognitionStatus.queued
assert job.request_msg_id is None
assert job.published_at is None
assert job.reply_deadline is None # откат: срок не сохранён
report = await _publish(session_factory, nats)
assert report.published == 2 # noqa: PLR2004 - оба задания
for job_id in (first, second):
await subscriber.wait_for(job_id, 1)
assert (await jobs.get(job_id)).status is RecognitionStatus.published
@pytest.mark.integration
async def test_republish_error_keeps_published_state(
session_factory: async_sessionmaker[AsyncSession], jobs: CommittedJobs, nats: NatsConnection
):
clock = Clock()
job_id = await jobs.make_queued_job(reply_deadline=clock() + timedelta(hours=1))
await _publish(session_factory, nats, clock)
published_at = (await jobs.get(job_id)).published_at
clock.advance(ACK + timedelta(seconds=1))
report = await _publish(session_factory, FakeNats(fail_first=1), clock)
assert report.failed == 1
job = await jobs.get(job_id)
assert job.status is RecognitionStatus.published
assert job.published_at == published_at
assert job.metrics is None
# --- конкурентность -----------------------------------------------------------------------------------------------
@pytest.mark.integration
async def test_concurrent_publishers_publish_each_job_once(
session_factory: async_sessionmaker[AsyncSession],
jobs: CommittedJobs,
nats: NatsConnection,
subscriber: RequestSubscriber,
):
"""Два одновременных прохода (SKIP LOCKED): каждое задание опубликовано ровно один раз."""
job_ids = [await jobs.make_queued_job() for _ in range(CONCURRENT_JOBS)]
# задержка внутри публикации держит блокировку строки, пока второй проход выбирает задания
first, second = FakeNats(delegate=nats, delay=0.02), FakeNats(delegate=nats, delay=0.02)
reports = await asyncio.gather(_publish(session_factory, first), _publish(session_factory, second))
assert sum(r.published for r in reports) == CONCURRENT_JOBS
assert all(r.published > 0 for r in reports) # оба прохода реально работали параллельно
published_ids = [msg_id for fake in (first, second) for _, msg_id in fake.calls]
assert sorted(published_ids) == sorted(f"{job_id}:1" for job_id in job_ids)
await subscriber.settle()
for job_id in job_ids:
assert len(await subscriber.wait_for(job_id, 1)) == 1
assert (await jobs.get(job_id)).status is RecognitionStatus.published
# --- вахтёр ----------------------------------------------------------------------------------------------------------
@pytest.mark.integration
@pytest.mark.parametrize(
("status", "deadline_offset", "expected"),
[
(RecognitionStatus.processing, timedelta(minutes=-1), RecognitionStatus.timeout),
(RecognitionStatus.published, timedelta(minutes=-1), RecognitionStatus.timeout),
(RecognitionStatus.processing, timedelta(minutes=5), RecognitionStatus.processing),
(RecognitionStatus.published, timedelta(minutes=5), RecognitionStatus.published),
(RecognitionStatus.processing, None, RecognitionStatus.processing),
(RecognitionStatus.queued, timedelta(minutes=-1), RecognitionStatus.queued),
(RecognitionStatus.done, timedelta(minutes=-1), RecognitionStatus.done),
(RecognitionStatus.dlq, timedelta(minutes=-1), RecognitionStatus.dlq),
],
)
async def test_expire_deadlines(
session_factory: async_sessionmaker[AsyncSession],
jobs: CommittedJobs,
status: RecognitionStatus,
deadline_offset: timedelta | None,
expected: RecognitionStatus,
):
"""Сценарий «Сервис взял, но не ответил»: `processing`/`published` с прошедшим сроком → `timeout`."""
clock = Clock()
deadline = None if deadline_offset is None else clock() + deadline_offset
job_id = await jobs.make_queued_job(status=status, reply_deadline=deadline)
expired = await expire_deadlines(session_factory, now=clock)
job = await jobs.get(job_id)
assert job.status is expected
if expected is RecognitionStatus.timeout:
assert expired == 1
assert job.error_code == "TIMEOUT"
assert job.error_message
assert job.finished_at == clock()
assert job.attempt == 1
else:
assert expired == 0
assert job.error_code is None
assert job.finished_at is None
@pytest.mark.integration
@pytest.mark.parametrize("file_status", [FileStatus.PROCESSING, FileStatus.UPLOADED])
@pytest.mark.parametrize("job_status", [RecognitionStatus.processing, RecognitionStatus.published])
async def test_expire_deadlines_moves_current_file_to_error(
session_factory: async_sessionmaker[AsyncSession],
jobs: CommittedJobs,
file_status: FileStatus,
job_status: RecognitionStatus,
):
"""Сценарий «Сервис не ответил» (primary-files): задание — `timeout`, файл — `ERROR` в той же транзакции."""
clock = Clock()
file_id, job_id = await jobs.make_file_job(
file_status=file_status, job_status=job_status, reply_deadline=clock() - timedelta(minutes=1)
)
assert await expire_deadlines(session_factory, now=clock) == 1
job, file = await jobs.get(job_id), await jobs.file(file_id)
assert (job.status, job.error_code) == (RecognitionStatus.timeout, "TIMEOUT")
assert (file.status, file.updated_at) == (FileStatus.ERROR, clock())
@pytest.mark.integration
@pytest.mark.parametrize("file_status", [FileStatus.PROCESSED, FileStatus.SUPERSEDED])
async def test_expire_deadlines_keeps_processed_and_superseded_file(
session_factory: async_sessionmaker[AsyncSession], jobs: CommittedJobs, file_status: FileStatus
):
clock = Clock()
file_id, job_id = await jobs.make_file_job(
file_status=file_status, job_status=RecognitionStatus.processing, reply_deadline=clock() - timedelta(minutes=1)
)
assert await expire_deadlines(session_factory, now=clock) == 1
assert (await jobs.get(job_id)).status is RecognitionStatus.timeout
file = await jobs.file(file_id)
assert (file.status, file.updated_at) == (file_status, None)
@pytest.mark.integration
async def test_expire_deadlines_of_non_current_job_keeps_file(
session_factory: async_sessionmaker[AsyncSession], jobs: CommittedJobs
):
"""Review Focus 1: таймаут старого задания файла, у которого уже новое текущее задание, файл не трогает."""
clock = Clock()
file_id, old_job = await jobs.make_file_job(
file_status=FileStatus.UPLOADED,
job_status=RecognitionStatus.processing,
reply_deadline=clock() - timedelta(minutes=1),
)
new_job = await jobs.add_job(file_id, status=RecognitionStatus.queued, current=True)
assert await expire_deadlines(session_factory, now=clock) == 1
assert (await jobs.get(old_job)).status is RecognitionStatus.timeout
file = await jobs.file(file_id)
assert (file.status, file.current_recognition_job_id, file.updated_at) == (FileStatus.UPLOADED, new_job, None)
@pytest.mark.integration
@pytest.mark.parametrize("current", [True, False], ids=["current", "non_current"])
async def test_dlq_moves_current_file_to_error(
session_factory: async_sessionmaker[AsyncSession], jobs: CommittedJobs, current: bool
):
"""Публикатор переводит задание в `dlq` — файл текущего задания `ERROR`; файл чужого задания не меняется."""
clock = Clock()
file_id, job_id = await jobs.make_file_job(
job_status=RecognitionStatus.published,
published_at=clock() - ACK * 2,
request_msg_id="dlq-test:1",
reply_deadline=clock() + timedelta(hours=1),
metrics={"republish_count": MAX_REPUBLISH},
)
# новое задание — `processing`, чтобы публикатор его не трогал (в проходе только задание старого)
new_job = None if current else await jobs.add_job(file_id, status=RecognitionStatus.processing, current=True)
report = await _publish(session_factory, FakeNats(), clock)
assert report.dlq == 1
assert (await jobs.get(job_id)).status is RecognitionStatus.dlq
file = await jobs.file(file_id)
if current:
assert (file.status, file.updated_at) == (FileStatus.ERROR, clock())
else:
assert (file.status, file.current_recognition_job_id, file.updated_at) == (FileStatus.UPLOADED, new_job, None)
# --- фоновые циклы -----------------------------------------------------------------------------------------------
async def _wait_until(predicate: Callable[[], bool], timeout: float = 2.0) -> None:
deadline = time.monotonic() + timeout
while not predicate():
if time.monotonic() > deadline:
raise AssertionError("условие не выполнено за отведённое время")
await asyncio.sleep(0.01)
async def _cancel(task: asyncio.Task[Any]) -> None:
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
@pytest.mark.integration
async def test_publisher_loop_publishes_and_stops_on_cancel(
session_factory: async_sessionmaker[AsyncSession],
jobs: CommittedJobs,
nats: NatsConnection,
subscriber: RequestSubscriber,
):
job_id = await jobs.make_queued_job()
task = asyncio.create_task(
run_publisher_loop(session_factory, nats, poll_seconds=0.1, ack_timeout=ACK, max_republish=MAX_REPUBLISH)
)
try:
await subscriber.wait_for(job_id, 1, timeout=2.0)
finally:
await _cancel(task)
assert (await jobs.get(job_id)).status is RecognitionStatus.published
@pytest.mark.integration
async def test_watchdog_loop_expires_and_stops_on_cancel(
session_factory: async_sessionmaker[AsyncSession], jobs: CommittedJobs
):
job_id = await jobs.make_queued_job(
status=RecognitionStatus.processing, reply_deadline=datetime.now(UTC) - timedelta(seconds=1)
)
task = asyncio.create_task(run_watchdog_loop(session_factory, period_seconds=0.1))
try:
deadline = time.monotonic() + 2.0
while (await jobs.get(job_id)).status is not RecognitionStatus.timeout:
assert time.monotonic() < deadline, "вахтёр не перевёл задание в timeout за 2 с"
await asyncio.sleep(0.05)
finally:
await _cancel(task)
class RecordingSleep:
"""Подмена `sleep` циклов: запоминает паузы и только уступает управление."""
def __init__(self) -> None:
self.delays: list[float] = []
async def __call__(self, delay: float) -> None:
self.delays.append(delay)
await asyncio.sleep(0)
async def test_publisher_loop_survives_pass_errors(monkeypatch: pytest.MonkeyPatch):
calls: list[int] = []
async def failing_pass(*args: Any, **kwargs: Any) -> Any:
calls.append(1)
raise RuntimeError("БД недоступна")
monkeypatch.setattr(rx, "publish_pending", failing_pass)
sleep = RecordingSleep()
task = asyncio.create_task(
run_publisher_loop(
object(), # type: ignore[arg-type] # проход подменён: фабрика сессий не используется
FakeNats(),
poll_seconds=5,
ack_timeout=ACK,
max_republish=MAX_REPUBLISH,
sleep=sleep,
)
)
try:
await _wait_until(lambda: len(calls) >= MIN_LOOP_PASSES)
finally:
await _cancel(task)
assert set(sleep.delays) == {5}
async def test_watchdog_loop_survives_pass_errors(monkeypatch: pytest.MonkeyPatch):
calls: list[int] = []
async def failing_pass(*args: Any, **kwargs: Any) -> int:
calls.append(1)
raise RuntimeError("БД недоступна")
monkeypatch.setattr(rx, "expire_deadlines", failing_pass)
sleep = RecordingSleep()
task = asyncio.create_task(run_watchdog_loop(object(), sleep=sleep)) # type: ignore[arg-type]
try:
await _wait_until(lambda: len(calls) >= MIN_LOOP_PASSES)
finally:
await _cancel(task)
assert set(sleep.delays) == {WATCHDOG_PERIOD_SECONDS}
async def test_exchange_tasks_start_two_loops_and_stop(monkeypatch: pytest.MonkeyPatch):
started: dict[str, dict[str, Any]] = {}
async def fake_publisher(session_factory: Any, nats: Any, **kwargs: Any) -> None:
started["publisher"] = kwargs
await asyncio.Event().wait()
async def fake_watchdog(session_factory: Any, **kwargs: Any) -> None:
started["watchdog"] = kwargs
await asyncio.Event().wait()
monkeypatch.setattr(rx, "run_publisher_loop", fake_publisher)
monkeypatch.setattr(rx, "run_watchdog_loop", fake_watchdog)
settings = get_settings().model_copy(
update={"OUTBOX_POLL_SECONDS": 7, "ACK_TIMEOUT_SECONDS": 11, "OUTBOX_MAX_REPUBLISH": 2}
)
exchange = ExchangeTasks.start(object(), FakeNats(), settings) # type: ignore[arg-type]
await _wait_until(lambda: len(started) == EXCHANGE_TASKS)
assert len(exchange.tasks) == EXCHANGE_TASKS
assert started["publisher"] == {
"poll_seconds": 7,
"ack_timeout": timedelta(seconds=11),
"max_republish": 2,
"request_subjects": {"tabular": "files.tabular_uploaded", "image": "files.img_uploaded"},
}
assert started["watchdog"] == {"period_seconds": WATCHDOG_PERIOD_SECONDS}
await exchange.stop()
assert all(task.done() for task in exchange.tasks)
await exchange.stop() # повторная остановка безопасна