Повторная публикация обновляет 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>
This commit is contained in:
parent
c14d5e3ffe
commit
c9d60907d3
@ -67,7 +67,7 @@
|
|||||||
(`docker build`; убран из `.gitignore` шаблона). CI в репозитории нет: перед PR — `make check-full`. Без Nexus:
|
(`docker build`; убран из `.gitignore` шаблона). CI в репозитории нет: перед PR — `make check-full`. Без Nexus:
|
||||||
`make venv` ставит зависимости без `raisa_*`, npm-пакеты `raisa-*` — `optionalDependencies`.
|
`make venv` ставит зависимости без `raisa_*`, npm-пакеты `raisa-*` — `optionalDependencies`.
|
||||||
- **NATS без JetStream** (`NATS_ENABLED=true`): `core/nats.py` — подключение в фоне (supervisor повторяет попытки),
|
- **NATS без JetStream** (`NATS_ENABLED=true`): `core/nats.py` — подключение в фоне (supervisor повторяет попытки),
|
||||||
подписка queue group `arm-core` на `files.tabular_parsed`. Обмен — `services/recognition_exchange.py`: `publish_pending`
|
подписка queue group `arm-core` на `files.tabular_parsed` (по умолчанию; задаётся OPENBAO__NATS__NATS_*_SUBJECT). Обмен — `services/recognition_exchange.py`: `publish_pending`
|
||||||
(outbox), `expire_deadlines` (вахтёр сроков), `handle_message` (inbox: ack — конверт `processing`, результат);
|
(outbox), `expire_deadlines` (вахтёр сроков), `handle_message` (inbox: ack — конверт `processing`, результат);
|
||||||
публикатор и вахтёр — фоновые задачи lifespan. Запрос без ack повторно публикуется (`ACK_TIMEOUT_SECONDS`=30 ×
|
публикатор и вахтёр — фоновые задачи lifespan. Запрос без ack повторно публикуется (`ACK_TIMEOUT_SECONDS`=30 ×
|
||||||
`OUTBOX_MAX_REPUBLISH`=3, счётчик — `recognition_job.metrics.republish_count`) → `dlq`; дедуп — `request_msg_id` /
|
`OUTBOX_MAX_REPUBLISH`=3, счётчик — `recognition_job.metrics.republish_count`) → `dlq`; дедуп — `request_msg_id` /
|
||||||
|
|||||||
@ -129,7 +129,7 @@ curl -s localhost:8000/healthcheck
|
|||||||
```
|
```
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"uptime":"2026-10-03T18:37:15.121394+00:00","version":"1.0.0","nats":"ok"}
|
{"uptime":"2026-10-03T18:37:15.121394+00:00","version":"1.0.0","nats":"ok","subjects":{"tabular_request":"files.tabular_uploaded","image_request":"files.img_uploaded","result":"files.tabular_parsed"}}
|
||||||
```
|
```
|
||||||
|
|
||||||
`nats`: `ok` — подключён, `down` — включён (`NATS_ENABLED=true`), но связи нет, `off` — выключен.
|
`nats`: `ok` — подключён, `down` — включён (`NATS_ENABLED=true`), но связи нет, `off` — выключен.
|
||||||
@ -1004,7 +1004,7 @@ tabular-parser (MinIO) → inbox → маппинг → `GET /files/{id}` = `PRO
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose -f ../docs/dev/docker-compose.yml -p arm-ka --profile services up -d --build --no-deps tabular-parser
|
docker compose -f ../docs/dev/docker-compose.yml -p arm-ka --profile services up -d --build --no-deps tabular-parser
|
||||||
curl -s localhost:8001/healthcheck # {"uptime": "...", "version": "...", "nats": "ok", "subjects": {"tabular_request": "...", "image_request": "...", "result": "..."}}
|
curl -s localhost:8001/healthcheck # {"uptime": "...", "version": "...", "nats": "ok", "subjects": {"tabular_request": "...", "result": "..."}}
|
||||||
make e2e # PASSED
|
make e2e # PASSED
|
||||||
docker compose -f ../docs/dev/docker-compose.yml -p arm-ka --profile services stop tabular-parser
|
docker compose -f ../docs/dev/docker-compose.yml -p arm-ka --profile services stop tabular-parser
|
||||||
```
|
```
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
"""Точка входа FastAPI. Каркас из шаблона команды: инфраструктура без доменного кода.
|
"""Точка входа FastAPI. Каркас из шаблона команды: инфраструктура без доменного кода.
|
||||||
|
|
||||||
Что здесь есть: настройки, подключение S3 (MinIO; `MINIO_SECURE` — https или http), NATS (`src/core/nats.py`: core
|
Что здесь есть: настройки, подключение S3 (MinIO; `MINIO_SECURE` — https или http), NATS (`src/core/nats.py`: core
|
||||||
NATS без JetStream, подключение в фоне при NATS_ENABLED=true независимо от DEBUG, подписка `files.tabular_parsed`
|
NATS без JetStream, подключение в фоне при NATS_ENABLED=true независимо от DEBUG, подписка на subject ответов
|
||||||
в queue group `arm-core`), SMB (горячие папки ИС Сканирование; необязателен — при пустом `SMB_HOST` не подключается),
|
(по умолчанию `files.tabular_parsed`) в queue group `arm-core`), SMB (горячие папки ИС Сканирование;
|
||||||
|
необязателен — при пустом `SMB_HOST` не подключается),
|
||||||
middleware (gzip, proxy headers, CORS по `cors_options`; при DEBUG=False пакет РАИСА лишь условно импортируется,
|
middleware (gzip, proxy headers, CORS по `cors_options`; при DEBUG=False пакет РАИСА лишь условно импортируется,
|
||||||
middleware не подключается), healthcheck (uptime, version, nats: ok|down|off, subjects), раздача собранного
|
middleware не подключается), healthcheck (uptime, version, nats: ok|down|off, subjects), раздача собранного
|
||||||
SPA. Документация API (`/docs`, `/redoc`, `/openapi.json`) — только при DEBUG (OQ-66). Приложение собирает
|
SPA. Документация API (`/docs`, `/redoc`, `/openapi.json`) — только при DEBUG (OQ-66). Приложение собирает
|
||||||
@ -11,8 +12,9 @@ SPA. Документация API (`/docs`, `/redoc`, `/openapi.json`) — то
|
|||||||
состояние. При NATS_ENABLED=true lifespan запускает фоновые задачи обмена `recognition_job` — outbox-публикатор,
|
состояние. При NATS_ENABLED=true lifespan запускает фоновые задачи обмена `recognition_job` — outbox-публикатор,
|
||||||
вахтёр сроков (`src/services/recognition_exchange.py`, design D-6) и, если S3 подключено, цикл маппинга результатов
|
вахтёр сроков (`src/services/recognition_exchange.py`, design D-6) и, если S3 подключено, цикл маппинга результатов
|
||||||
(`src/services/result_mapping.py`, Срез 2c D-1) — и останавливает их до закрытия NATS. Сообщения
|
(`src/services/result_mapping.py`, Срез 2c D-1) — и останавливает их до закрытия NATS. Сообщения
|
||||||
`files.tabular_parsed` (ack и результаты сервисов) обрабатывает inbox `handle_message` того же модуля с фабрикой сессий
|
subject ответов (по умолчанию `files.tabular_parsed`; ack и результаты сервисов) обрабатывает inbox
|
||||||
приложения (inbox меняет задание и статус файла; маппинг результата в доменные таблицы — отдельный цикл, Срез 2c D-1).
|
`handle_message` того же модуля с фабрикой сессий приложения (inbox меняет задание и статус файла; маппинг
|
||||||
|
результата в доменные таблицы — отдельный цикл, Срез 2c D-1).
|
||||||
Остановка: фоновые задачи обмена → NATS → S3 → SMB → пул соединений БД; каждый шаг независим — ошибка одного
|
Остановка: фоновые задачи обмена → NATS → S3 → SMB → пул соединений БД; каждый шаг независим — ошибка одного
|
||||||
логируется и не отменяет остальные. Логгер настраивает `setup_logger` при импорте модуля: без значений локальных
|
логируется и не отменяет остальные. Логгер настраивает `setup_logger` при импорте модуля: без значений локальных
|
||||||
переменных в трейсбеках (#63).
|
переменных в трейсбеках (#63).
|
||||||
@ -110,13 +112,14 @@ async def _connect_s3() -> S3Storage | None:
|
|||||||
|
|
||||||
|
|
||||||
def _result_handler() -> MessageHandler:
|
def _result_handler() -> MessageHandler:
|
||||||
"""Inbox `files.tabular_parsed`: `handle_message` с фабрикой сессий приложения (маппинг результата — отдельный цикл,
|
"""Inbox subject ответов (по умолчанию `files.tabular_parsed`): `handle_message` с фабрикой сессий приложения
|
||||||
Срез 2c design D-1)."""
|
(маппинг результата — отдельный цикл, Срез 2c design D-1)."""
|
||||||
return partial(handle_message, session_factory=async_session_maker)
|
return partial(handle_message, session_factory=async_session_maker)
|
||||||
|
|
||||||
|
|
||||||
async def _subscribe_results(nats: NatsConnection) -> None:
|
async def _subscribe_results(nats: NatsConnection) -> None:
|
||||||
"""Подписки после первого подключения к NATS: `files.tabular_parsed` в queue group `arm-core`."""
|
"""Подписки после первого подключения к NATS: subject ответов (по умолчанию `files.tabular_parsed`) в queue
|
||||||
|
group `arm-core`."""
|
||||||
await nats.subscribe_results(_result_handler())
|
await nats.subscribe_results(_result_handler())
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -105,7 +105,7 @@ class NatsConnection:
|
|||||||
|
|
||||||
`on_connected(conn)` вызывается один раз после первого успешного подключения (подписки); при исключении
|
`on_connected(conn)` вызывается один раз после первого успешного подключения (подписки); при исключении
|
||||||
повторяется с той же экспоненциальной паузой. `result_subject` и `queue` параметризуются для тестов —
|
повторяется с той же экспоненциальной паузой. `result_subject` и `queue` параметризуются для тестов —
|
||||||
по умолчанию `files.tabular_parsed` и `arm-core`.
|
по умолчанию `files.tabular_parsed` (фактический subject задаётся настройками окружения) и `arm-core`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__( # noqa: PLR0913 - все параметры, кроме settings, именованные: подмены для тестов и тайминги
|
def __init__( # noqa: PLR0913 - все параметры, кроме settings, именованные: подмены для тестов и тайминги
|
||||||
|
|||||||
@ -26,7 +26,8 @@ SKIP LOCKED` → публикация → `UPDATE` → `COMMIT`). Блокиро
|
|||||||
|
|
||||||
Обмен не пишет `audit_event` (рулинг R-7, design #20; ASSUMPTION(OQ-55)).
|
Обмен не пишет `audit_event` (рулинг R-7, design #20; ASSUMPTION(OQ-55)).
|
||||||
|
|
||||||
Inbox (задача 24, рулинги R-12, R-22): `handle_message()` — обработчик подписки `files.tabular_parsed`
|
Inbox (задача 24, рулинги R-12, R-22): `handle_message()` — обработчик подписки на subject ответов
|
||||||
|
(по умолчанию `files.tabular_parsed`)
|
||||||
(`src/core/nats.py`, queue group `arm-core`). Одно сообщение — одна короткая транзакция без сетевого IO:
|
(`src/core/nats.py`, queue group `arm-core`). Одно сообщение — одна короткая транзакция без сетевого IO:
|
||||||
`SELECT … FOR UPDATE` строки задания (ЖДЁТ блокировку: ack, пришедший, пока публикатор держит строку до своего commit,
|
`SELECT … FOR UPDATE` строки задания (ЖДЁТ блокировку: ack, пришедший, пока публикатор держит строку до своего commit,
|
||||||
применяется после него, а не теряется) → проверки статуса → изменения → `COMMIT`.
|
применяется после него, а не теряется) → проверки статуса → изменения → `COMMIT`.
|
||||||
@ -299,7 +300,7 @@ async def _process_one( # noqa: PLR0913 - один шаг прохода: вс
|
|||||||
metrics = base.op("||", return_type=JSONB)(literal({REPUBLISH_COUNT_KEY: count}, JSONB))
|
metrics = base.op("||", return_type=JSONB)(literal({REPUBLISH_COUNT_KEY: count}, JSONB))
|
||||||
await session.execute(
|
await session.execute(
|
||||||
_job_update(row.id, RecognitionStatus.published).values(
|
_job_update(row.id, RecognitionStatus.published).values(
|
||||||
published_at=now, reply_deadline=reply_deadline, metrics=metrics
|
published_at=now, reply_deadline=reply_deadline, metrics=metrics, request_subject=subject
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
report.republished += 1
|
report.republished += 1
|
||||||
@ -508,7 +509,7 @@ class ExchangeTasks:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# --- inbox: files.tabular_parsed ----------------------------------------------------------------------------------
|
# --- inbox: subject ответов ----------------------------------------------------------------------------------
|
||||||
|
|
||||||
# Финальные статусы для inbox (рулинг R-22, design #34): результат для них не применяется
|
# Финальные статусы для inbox (рулинг R-22, design #34): результат для них не применяется
|
||||||
FINAL_STATUSES: frozenset[RecognitionStatus] = frozenset(
|
FINAL_STATUSES: frozenset[RecognitionStatus] = frozenset(
|
||||||
@ -752,7 +753,7 @@ async def _apply_message(
|
|||||||
job = await _lock_job(session, message.job_id)
|
job = await _lock_job(session, message.job_id)
|
||||||
if job is None:
|
if job is None:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Сообщение files.tabular_parsed для неизвестного задания, не применено. error_category=unknown_job, "
|
"Сообщение на subject ответа для неизвестного задания, не применено. error_category=unknown_job, "
|
||||||
"job_id={job_id}, status={status}",
|
"job_id={job_id}, status={status}",
|
||||||
job_id=message.job_id,
|
job_id=message.job_id,
|
||||||
status=message.status.value,
|
status=message.status.value,
|
||||||
@ -772,7 +773,7 @@ async def _apply_message(
|
|||||||
if msg_id is None:
|
if msg_id is None:
|
||||||
msg_id = ack_msg_id(job.id, job.attempt) if is_ack else result_msg_id(job.id, job.attempt)
|
msg_id = ack_msg_id(job.id, job.attempt) if is_ack else result_msg_id(job.id, job.attempt)
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Сообщение files.tabular_parsed без заголовка Nats-Msg-Id: использован производный id. "
|
"Сообщение на subject ответа без заголовка Nats-Msg-Id: использован производный id. "
|
||||||
"error_category=missing_msg_id, job_id={job_id}, msg_id={msg_id}",
|
"error_category=missing_msg_id, job_id={job_id}, msg_id={msg_id}",
|
||||||
job_id=job.id,
|
job_id=job.id,
|
||||||
msg_id=msg_id,
|
msg_id=msg_id,
|
||||||
@ -790,8 +791,9 @@ async def handle_message(
|
|||||||
session_factory: async_sessionmaker[AsyncSession],
|
session_factory: async_sessionmaker[AsyncSession],
|
||||||
now: Clock | None = None,
|
now: Clock | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Обработчик `files.tabular_parsed` (spec «Ack сервиса переводит задание в `processing`», «Inbox принимает
|
"""Обработчик subject ответов (по умолчанию
|
||||||
результат идемпотентно»; design D-6). Модель транзакции и правила — в docstring модуля.
|
`files.tabular_parsed`; spec «Ack сервиса переводит задание в `processing`», «Inbox принимает результат
|
||||||
|
идемпотентно»; design D-6). Модель транзакции и правила — в docstring модуля.
|
||||||
|
|
||||||
Исключения наружу не выходят: невалидное сообщение — warning; ошибка БД — откат транзакции (задание и файл),
|
Исключения наружу не выходят: невалидное сообщение — warning; ошибка БД — откат транзакции (задание и файл),
|
||||||
`logger.exception`, без повторов (задание дойдёт до `timeout`). `audit_event` не пишется (R-7,
|
`logger.exception`, без повторов (задание дойдёт до `timeout`). `audit_event` не пишется (R-7,
|
||||||
|
|||||||
@ -285,6 +285,44 @@ async def test_publish_uses_configured_request_subject(
|
|||||||
assert (await jobs.get(job_id)).request_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
|
@pytest.mark.integration
|
||||||
async def test_publish_keeps_existing_reply_deadline(
|
async def test_publish_keeps_existing_reply_deadline(
|
||||||
session_factory: async_sessionmaker[AsyncSession],
|
session_factory: async_sessionmaker[AsyncSession],
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user