Срез 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 asyncio
import json import json
import re import re
import uuid
from collections import OrderedDict from collections import OrderedDict
from collections.abc import Awaitable, Callable, Coroutine from collections.abc import Awaitable, Callable, Coroutine
from dataclasses import dataclass from dataclasses import dataclass
@ -284,7 +285,11 @@ class Worker:
return _Rejection(f"запрос не соответствует схеме recognition-request: {details}", raw) return _Rejection(f"запрос не соответствует схеме recognition-request: {details}", raw)
def _reject(self, rejection: _Rejection) -> None: 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 {} raw = rejection.raw or {}
job_id, file_id = raw.get("job_id"), raw.get("file_id") job_id, file_id = raw.get("job_id"), raw.get("file_id")
if not (_is_uuid(job_id) and _is_uuid(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), reason=clean_text(rejection.reason, MAX_FRAGMENT_CHARS),
) )
return 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") attempt = raw.get("attempt")
if not (type(attempt) is int and 1 <= attempt <= _MAX_ATTEMPT): if not (type(attempt) is int and 1 <= attempt <= _MAX_ATTEMPT):
attempt = 1 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 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: async def test_publish_failure_is_logged_and_worker_keeps_working(make_env: EnvFactory, log_lines: list[str]) -> None:
env = await make_env() env = await make_env()