arm-scan-gateway/api/tests/unit/test_worker_concurrency.py

239 lines
12 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.

"""Параллельная обработка ограничена (spec image-recognition-gateway, задача 5.4; D-3, `SCAN_MAX_INFLIGHT`).
Сценарий spec «Пять заданий подряд»: пять запросов при `SCAN_MAX_INFLIGHT = 4` → пять ack'ов сразу, четыре файла во
входящем каталоге, пятый — только после завершения одного из четырёх. Плюс строгая очерёдность при одном слоте и
остановка воркера, когда задания ждут слот (T12.9).
Детерминизм без «поспать и посмотреть»: пауза между опросами горячей папки подменена (`SteppedSleep`) — задание,
записавшее файл и опросившее исходящий каталог, «паркуется» и опрашивает снова только по шагу теста. Ожидания —
по событиям (`asyncio.Condition`) с явным тайм-аутом. Ответ ИС кладёт сам тест (`env.answer`) и ровно для того
задания, слот которого освобождается. Чтение S3 у фейка не уступает цикл событий, поэтому задание, прошедшее мимо
семафора, читает S3 в том же проходе цикла, что и его ack, — счётчик чтений S3 различает «ждёт слот» и «слот не нужен»
без гонок.
"""
import asyncio
import json
from collections import Counter
from typing import Any
import pytest
from tests.worker_helpers import SUCCESS_ANSWER, Env, EnvFactory
# Логи воркера перехватываются (`log_lines` из conftest): вывод pytest чистый
pytestmark = pytest.mark.usefixtures("log_lines")
WAIT_TIMEOUT = 3.0
class SteppedSleep:
"""Подмена `sleep` воркера: пауза между опросами длится, пока тест не сделает шаг (`step`) или не откроет её.
Поколение растёт с каждым шагом: `wait_parked(n)` ждёт n заданий, припаркованных уже после последнего шага, —
так ожидание не срабатывает на заданиях, которые только что отпущены, но ещё не успели проснуться.
"""
def __init__(self) -> None:
self._generation = 0
self._parked: Counter[int] = Counter()
self._gate = asyncio.Event()
self._opened = False
self._changed = asyncio.Condition()
@property
def parked(self) -> int:
"""Сколько заданий припарковано после последнего шага."""
return self._parked[self._generation]
async def __call__(self, seconds: float) -> None:
if self._opened:
await asyncio.sleep(0)
return
generation, gate = self._generation, self._gate
async with self._changed:
self._parked[generation] += 1
self._changed.notify_all()
try:
await gate.wait()
finally:
self._parked[generation] -= 1
def step(self) -> None:
"""Отпустить всех припаркованных: каждое задание опросит исходящий каталог ещё раз."""
self._gate.set()
self._gate = asyncio.Event()
self._generation += 1
def open(self) -> None:
"""Снять паузы совсем: дальше опросы идут без ожидания."""
self._opened = True
self._gate.set()
async def wait_parked(self, count: int) -> None:
async with self._changed:
await asyncio.wait_for(self._changed.wait_for(lambda: self.parked >= count), WAIT_TIMEOUT)
def job_of(name: str) -> str:
"""`job_id` из имени файла входящего каталога `<job_id>.<ext>`."""
return name.split(".", 1)[0]
def answer(env: Env, job_id: str) -> None:
"""Ответ ИС для задания (синтетический успех R-10 → `NOT_IMPLEMENTED`)."""
env.answer(job_id, json.dumps({**SUCCESS_ANSWER, "file_uid": job_id}).encode())
def index_of(env: Env, event: tuple[str, str]) -> int:
assert env.events.count(event) == 1, (event, env.events)
return env.events.index(event)
def source_key(request: dict[str, Any]) -> str:
return str(request["s3"]["key"])
async def finish_all(env: Env, poll_sleep: SteppedSleep, jobs: list[str]) -> None:
"""Ответить на все задания, снять паузы, дождаться конца; у каждого задания — ровно один ack и один итог."""
for job in jobs:
if not env.local.answer_path(job).exists():
answer(env, job)
poll_sleep.open()
await asyncio.wait_for(env.worker.wait_idle(), WAIT_TIMEOUT)
for job in jobs:
assert env.acks(job) == [f"ack:{job}:1"]
[(msg_id, body)] = env.results(job)
assert msg_id == f"result:{job}:1"
assert body["status"] == "error"
assert body["error"]["code"] == "NOT_IMPLEMENTED"
assert env.worker.active_jobs == 0
# --- spec «Пять заданий подряд» -----------------------------------------------------------------------------------
async def test_five_jobs_with_four_slots(make_env: EnvFactory) -> None:
poll_sleep = SteppedSleep()
env = await make_env(SCAN_MAX_INFLIGHT=4, SCAN_TIMEOUT_SECONDS=60, sleep=poll_sleep)
requests = [env.request() for _ in range(5)]
jobs = [request["job_id"] for request in requests]
for request in requests:
await env.deliver(request)
# Четыре слота заняты: каждое из четырёх заданий записало файл и ждёт ответа ИС
await poll_sleep.wait_parked(4)
# пять ack'ов уже опубликованы — ни один слот ещё не освободился, итогов нет
assert [env.acks(job) for job in jobs] == [[f"ack:{job}:1"] for job in jobs]
assert all(env.results(job) == [] for job in jobs)
# во входящем каталоге ровно четыре файла, пятое задание даже не читало S3
assert len(env.storage.get_calls) == 4
assert len(env.hotfolder.puts) == 4
assert env.inbox() == sorted(env.hotfolder.puts)
busy = [job_of(name) for name in env.hotfolder.puts]
[waiting] = [job for job in jobs if job not in busy]
assert poll_sleep.parked == 4
assert env.worker.active_jobs == 5
# Ещё один круг опроса занятых слотов: пятое задание по-прежнему ждёт
poll_sleep.step()
await poll_sleep.wait_parked(4)
assert len(env.hotfolder.puts) == 4
assert len(env.storage.get_calls) == 4
assert waiting not in {job_of(name) for name in env.inbox()}
# Ответ ИС для одного задания: его слот освобождается, и только тогда появляется пятый файл
freed = busy[0]
answer(env, freed)
poll_sleep.step()
await poll_sleep.wait_parked(4) # три прежних задания + пятое, записавшее файл
assert [job_of(name) for name in env.hotfolder.puts] == [*busy, waiting]
assert len(env.inbox()) == 5
assert all(env.results(job) == [] for job in jobs if job != freed)
freed_result = index_of(env, ("publish", f"result:{freed}:1"))
waiting_request = requests[jobs.index(waiting)]
assert max(index_of(env, ("publish", f"ack:{job}:1")) for job in jobs) < freed_result
assert freed_result < index_of(env, ("s3_get", source_key(waiting_request)))
assert freed_result < index_of(env, ("put", f"{waiting}.pdf"))
# Все пять заданий заканчиваются конвертами
await finish_all(env, poll_sleep, jobs)
assert len(env.inbox()) == 5
async def test_single_slot_serialises_jobs(make_env: EnvFactory) -> None:
poll_sleep = SteppedSleep()
env = await make_env(SCAN_MAX_INFLIGHT=1, SCAN_TIMEOUT_SECONDS=60, sleep=poll_sleep)
requests = [env.request() for _ in range(3)]
jobs = [request["job_id"] for request in requests]
for request in requests:
await env.deliver(request)
for done in range(3):
await poll_sleep.wait_parked(1)
# в работе ровно одно задание; ack'и всех трёх — сразу
assert len(env.hotfolder.puts) == done + 1
assert len(env.storage.get_calls) == done + 1
assert len(env.inbox()) == done + 1
assert all(env.acks(job) == [f"ack:{job}:1"] for job in jobs)
current = job_of(env.hotfolder.puts[-1])
assert env.results(current) == []
answer(env, current)
poll_sleep.step()
await asyncio.wait_for(env.worker.wait_idle(), WAIT_TIMEOUT)
# Строгая очерёдность в журнале: следующий файл записан только после итога предыдущего задания
order = [job_of(name) for name in env.hotfolder.puts]
assert sorted(order) == sorted(jobs)
files_and_results = [
(kind, ref) for kind, ref in env.events if kind == "put" or (kind == "publish" and ref.startswith("result:"))
]
expected: list[tuple[str, str]] = []
for job in order:
expected += [("put", f"{job}.pdf"), ("publish", f"result:{job}:1")]
assert files_and_results == expected
await finish_all(env, poll_sleep, jobs)
# --- остановка при очереди на слот (T12.9) ------------------------------------------------------------------------
async def test_stop_while_jobs_wait_for_slot(make_env: EnvFactory, log_lines: list[str]) -> None:
poll_sleep = SteppedSleep()
env = await make_env(SCAN_MAX_INFLIGHT=2, SCAN_TIMEOUT_SECONDS=60, sleep=poll_sleep)
tasks_before = asyncio.all_tasks()
requests = [env.request() for _ in range(5)]
for request in requests:
await env.deliver(request)
await poll_sleep.wait_parked(2)
assert env.worker.active_jobs == 5
assert len(env.hotfolder.puts) == 2 # три задания ждут слот
assert all(env.acks(request["job_id"]) == [f"ack:{request['job_id']}:1"] for request in requests)
job_tasks = asyncio.all_tasks() - tasks_before
assert len(job_tasks) == 5
# без исключения; `asyncio.timeout`, а не `wait_for`: отдельная задача-обёртка дала бы отменённым заданиям
# лишние проходы цикла, и проверка «stop() дождался заданий» стала бы слепой
async with asyncio.timeout(WAIT_TIMEOUT):
await env.worker.stop()
# Все задачи заданий завершены отменой, новых задач не осталось, итоговых конвертов нет
assert all(task.cancelled() for task in job_tasks)
assert asyncio.all_tasks() - tasks_before == set()
assert env.worker.active_jobs == 0
assert all(env.results(request["job_id"]) == [] for request in requests)
assert not [line for line in log_lines if line.startswith(("ERROR|", "CRITICAL|"))]
# После остановки файлы во входящий каталог больше не пишутся и S3 не читается, даже если цикл событий крутится
for _ in range(20):
await asyncio.sleep(0)
assert len(env.hotfolder.puts) == 2
assert len(env.inbox()) == 2
assert len(env.storage.get_calls) == 2
assert poll_sleep.parked == 0
# Слоты не утекли: после повторного запуска оба слота свободны
env.worker.start()
again = [env.request() for _ in range(3)]
for request in again:
await env.deliver(request)
await poll_sleep.wait_parked(2)
assert len(env.hotfolder.puts) == 4
assert len(env.storage.get_calls) == 4
await finish_all(env, poll_sleep, [request["job_id"] for request in again])