Срез 0 [fix T12.1]: SCHEMA_MISMATCH для задания в работе/обработанного не публикуется

Конверт `result:<job_id>:<attempt>` битого запроса совпадал по Nats-Msg-Id с настоящим результатом, и ядро
(дедуп по result_msg_id) отбросило бы настоящий итог. `_reject` нормализует job_id (`uuid.UUID`, нижний регистр)
и при `_seen` только пишет предупреждение. Тест: битый запрос с тем же job_id во время ожидания ИС и после итога,
в том числе UUID в верхнем регистре — опубликованы только ack и настоящий итог.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-04 05:02:28 +04:00
parent 38f67bc582
commit 0420c85219
2 changed files with 43 additions and 1 deletions

View File

@ -19,6 +19,7 @@ Spec image-recognition-gateway; design D-2, D-3, D-4, D-7; решения R-14..
import asyncio
import json
import re
import uuid
from collections import OrderedDict
from collections.abc import Awaitable, Callable, Coroutine
from dataclasses import dataclass
@ -284,7 +285,11 @@ class Worker:
return _Rejection(f"запрос не соответствует схеме recognition-request: {details}", raw)
def _reject(self, rejection: _Rejection) -> None:
"""`SCHEMA_MISMATCH` без ack (R-16): конверт — только если в теле есть корректные `job_id` и `file_id`."""
"""`SCHEMA_MISMATCH` без ack (R-16): конверт — только если в теле есть корректные `job_id` и `file_id`.
Для `job_id` в работе или уже обработанного конверт не публикуется (T12.1): его `Nats-Msg-Id`
`result:<job_id>:<attempt>` совпал бы с настоящим результатом, и ядро отбросило бы настоящий как дубль.
"""
raw = rejection.raw or {}
job_id, file_id = raw.get("job_id"), raw.get("file_id")
if not (_is_uuid(job_id) and _is_uuid(file_id)):
@ -293,6 +298,16 @@ class Worker:
reason=clean_text(rejection.reason, MAX_FRAGMENT_CHARS),
)
return
# `_active`/`_done` хранят канонический вид (нижний регистр), а UUID в теле может прийти в верхнем
key = str(uuid.UUID(str(job_id)))
if self._seen(key):
logger.warning(
"SCHEMA_MISMATCH для задания в работе/обработанного: конверт не публикуется. job_id={job_id}, "
"reason={reason}",
job_id=key,
reason=clean_text(rejection.reason, MAX_FRAGMENT_CHARS),
)
return
attempt = raw.get("attempt")
if not (type(attempt) is int and 1 <= attempt <= _MAX_ATTEMPT):
attempt = 1

View File

@ -494,6 +494,33 @@ async def test_schema_mismatch_does_not_register_job_id(make_env: EnvFactory) ->
assert len(env.hotfolder.puts) == 1
async def test_schema_mismatch_for_active_or_done_job_id_is_not_published(
make_env: EnvFactory, log_lines: list[str]
) -> None:
"""T12.1: битый запрос с job_id задания в работе или уже обработанного не публикует `result:<job>:<attempt>`.
Иначе ядро (дедуп по `result_msg_id`) отбросило бы настоящий итог с тем же Nats-Msg-Id. UUID в теле может прийти
в верхнем регистре — ключ дедупликации нормализуется.
"""
env = await make_env()
request = env.request()
job_id = request["job_id"]
broken = {**request, "schema_version": "2.0"}
await env.deliver(request)
await eventually(lambda: len(env.hotfolder.puts) == 1) # задание ждёт ответа ИС
await env.deliver(broken)
await env.deliver({**broken, "job_id": job_id.upper()})
async with emulator(env):
await asyncio.wait_for(env.worker.wait_idle(), 5)
await env.deliver(broken) # задание уже обработано
await env.deliver({**broken, "job_id": job_id.upper()})
await asyncio.wait_for(env.worker.wait_idle(), 5)
published = env.published()
assert [mid for mid, _ in published] == [f"ack:{job_id}:1", f"result:{job_id}:1"]
assert_error(published[1][1], ErrorCode.NOT_IMPLEMENTED, raw=True)
assert sum("SCHEMA_MISMATCH для задания в работе/обработанного" in line for line in log_lines) == 4
# --- публикация, размер и кодировка -------------------------------------------------------------------------------
async def test_publish_failure_is_logged_and_worker_keeps_working(make_env: EnvFactory, log_lines: list[str]) -> None:
env = await make_env()