fix: SMB необязателен, остановка lifespan по независимым шагам (I-3, T4.5, M-3)

- OPENBAO__SMB__* необязательны (по умолчанию пусто); при пустом SMB_HOST
  сессия SMB не регистрируется (DEBUG «SMB не настроен»), в .env.example
  и .env.test SMB выключен — в логе dev-запуска больше нет трейсбэка SMB.
- Задан SMB_HOST: register_session и reset_connection_cache вызываются
  через asyncio.to_thread и не блокируют цикл событий.
- Остановка: задачи обмена -> NATS -> S3 -> SMB -> engine.dispose();
  каждый шаг в своём try/except с логом step=..., ошибка одного шага не
  отменяет следующие. Пул соединений БД закрывается последним (M-3).
- README: «что в логе при запуске», таблица настроек SMB, порядок
  остановки.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-04 01:53:23 +04:00
parent 6a6ffb54f4
commit 3d31c1eac5
7 changed files with 226 additions and 50 deletions

View File

@ -155,21 +155,23 @@ Dev-пользователи (`api/seed/dev/users.yaml`, создаются `mak
### Что вы увидите в логе при запуске
Старт занимает около секунды; в логе один трейсбэк — это ожидаемо в dev, приложение при этом работает:
- Трейсбэк SMB: `Ошибка при регистрации сессии SMB ... Failed to connect to 'localhost:445': Connection refused`.
Настройки `OPENBAO__SMB__*` — заглушки, SMB в Срезе 0 не используется; игнорируйте. Пока на `localhost:445` ничего
не слушает, отказ приходит сразу (старт не задерживается); если порт отвечает, но молчит, ожидание ограничено
тайм-аутом `smbclient` (60 с).
Старт занимает около секунды; трейсбэков в логе нет. SMB (горячие папки ИС Сканирование из шаблона) необязателен и по
умолчанию выключен: в `.env.example` `OPENBAO__SMB__SMB_HOST` пуст, сессия SMB не регистрируется (сообщение об этом —
только на уровне DEBUG). Если `SMB_HOST` задан, регистрация выполняется в отдельном потоке (цикл событий не
блокируется; старт ждёт её завершения не дольше тайм-аута `smbclient`, 60 с); ошибка подключения логируется трейсбэком
`Ошибка при регистрации сессии SMB`, приложение продолжает работу.
Строки `Бакет проверен`, `Подключение к NATS установлено` и `Подписка NATS активна. subject=recognition.result,
queue=arm-core` означают, что S3 и NATS из compose подключены (MinIO из compose слушает `http`, поэтому в `.env`
задано `OPENBAO__MINIO__MINIO_SECURE=false`).
Недоступность SMB, S3 или NATS при старте приложение не роняет и не задерживает: ошибка логируется, состояние NATS
видно в `/healthcheck`. К NATS ядро подключается в фоне и повторяет попытки само (`nats=down`, пока связи нет; warning
в логе — на первую ошибку и дальше не чаще раза в минуту); после разрыва nats-py переподключается и восстанавливает
подписку.
Недоступность S3 или NATS при старте приложение не роняет и не задерживает (недоступный SMB, если он настроен, — не
роняет): ошибка логируется, состояние NATS видно в `/healthcheck`. К NATS ядро подключается в фоне и повторяет попытки
само (`nats=down`, пока связи нет; warning в логе — на первую ошибку и дальше не чаще раза в минуту); после разрыва
nats-py переподключается и восстанавливает подписку.
При остановке ресурсы закрываются по шагам: фоновые задачи обмена → NATS → S3 → SMB → пул соединений БД. Ошибка
одного шага логируется (`Ошибка при остановке приложения. step=...`) и не отменяет следующие.
Предупреждение «Включён dev-эмитент токенов … только для разработки» появляется не при старте, а при первом
запросе к защищённому эндпоинту (`/api/v1/me`): проверяющий токены создаётся лениво (штатно, см. «Безопасность
@ -207,10 +209,10 @@ docker compose -f ../docs/dev/docker-compose.yml down -v # полный сб
| `OPENBAO__NATS__NATS_URL` | — (обязательно) | адрес NATS (`nats://localhost:4222`) |
| `OPENBAO__NATS__NATS_SEED` | пусто | nkey seed; пусто — подключение без учётных данных (compose, тесты). Задаётся вместе с `NATS_JWT` или не задаётся вовсе |
| `OPENBAO__NATS__NATS_JWT` | пусто | JWT NATS; см. `NATS_SEED` |
| `OPENBAO__SMB__SMB_HOST` | — (обязательно) | заглушка SMB (в Срезе 0 не используется, блок обязателен) |
| `OPENBAO__SMB__SMB_PATH` | — (обязательно) | заглушка SMB |
| `OPENBAO__SMB__SMB_LOGIN` | — (обязательно) | заглушка SMB |
| `OPENBAO__SMB__SMB_PASSWORD` | — (обязательно) | заглушка SMB |
| `OPENBAO__SMB__SMB_HOST` | пусто | SMB из шаблона, необязателен (ядру в Срезе 0 не нужен). Пусто — сессия SMB не регистрируется; задан — регистрация при старте (в отдельном потоке) и сброс кэша соединений при остановке |
| `OPENBAO__SMB__SMB_PATH` | пусто | путь SMB (в Срезе 0 не используется) |
| `OPENBAO__SMB__SMB_LOGIN` | пусто | логин SMB |
| `OPENBAO__SMB__SMB_PASSWORD` | пусто | пароль SMB |
| `NATS_ENABLED` | `false` | подключение к NATS и обмен через `recognition_job`: фоновые outbox-публикатор и вахтёр сроков ответа (период 30 с, `processing`/`published` с прошедшим `reply_deadline` → `timeout`); без него фоновых задач нет, `/healthcheck` показывает `nats=off` |
| `OUTBOX_POLL_SECONDS` | `5` | период опроса outbox (`queued` → публикация → `published`) |
| `ACK_TIMEOUT_SECONDS` | `30` | ожидание ack сервиса распознавания до повторной публикации |

View File

@ -19,11 +19,11 @@ OPENBAO__NATS__NATS_URL=nats://localhost:4222
OPENBAO__NATS__NATS_SEED=
OPENBAO__NATS__NATS_JWT=
# SMB — заглушки (в Срезе 0 не используется)
OPENBAO__SMB__SMB_HOST=localhost
OPENBAO__SMB__SMB_PATH=share
OPENBAO__SMB__SMB_LOGIN=user
OPENBAO__SMB__SMB_PASSWORD=password
# SMB необязателен (ядру в Срезе 0 не нужен): пустой SMB_HOST — сессия SMB не регистрируется
OPENBAO__SMB__SMB_HOST=
OPENBAO__SMB__SMB_PATH=
OPENBAO__SMB__SMB_LOGIN=
OPENBAO__SMB__SMB_PASSWORD=
# Аутентификация. Dev-эмитент работает только при AUTH_DEV_ISSUER=true И DEBUG=true.
AUTH_DEV_ISSUER=true

View File

@ -17,10 +17,11 @@ OPENBAO__NATS__NATS_URL=nats://localhost:4222
OPENBAO__NATS__NATS_SEED=
OPENBAO__NATS__NATS_JWT=
OPENBAO__SMB__SMB_HOST=localhost
OPENBAO__SMB__SMB_PATH=share
OPENBAO__SMB__SMB_LOGIN=user
OPENBAO__SMB__SMB_PASSWORD=password
# SMB необязателен: пустой SMB_HOST — сессия SMB не регистрируется
OPENBAO__SMB__SMB_HOST=
OPENBAO__SMB__SMB_PATH=
OPENBAO__SMB__SMB_LOGIN=
OPENBAO__SMB__SMB_PASSWORD=
AUTH_DEV_ISSUER=true
AUTH_DEV_SECRET=test-only-secret-not-for-production-0123456789

View File

@ -2,17 +2,21 @@
Что здесь есть: настройки, подключение S3 (MinIO; `MINIO_SECURE` — https или http), NATS (`src/core/nats.py`: core
NATS без JetStream, подключение в фоне при NATS_ENABLED=true независимо от DEBUG, подписка `recognition.result` в queue
group `arm-core`), SMB (горячие папки ИС Сканирование), middleware (gzip, proxy headers, CORS; при DEBUG=False пакет
РАИСА лишь условно импортируется, middleware не подключается), healthcheck (uptime, version, nats: ok|down|off), раздача
собранного SPA.
group `arm-core`), SMB (горячие папки ИС Сканирование; необязателен — при пустом `SMB_HOST` не подключается),
middleware (gzip, proxy headers, CORS; при DEBUG=False пакет РАИСА лишь условно импортируется, middleware не
подключается), healthcheck (uptime, version, nats: ok|down|off), раздача собранного SPA.
Недоступность S3 или NATS при старте не роняет и не задерживает приложение: ошибка логируется, healthcheck показывает
состояние. При NATS_ENABLED=true lifespan запускает фоновые задачи обмена `recognition_job` — outbox-публикатор и
вахтёр сроков (`src/services/recognition_exchange.py`, design D-6) — и останавливает их до закрытия NATS. Сообщения
`recognition.result` (ack и результаты сервисов) обрабатывает inbox `handle_message` того же модуля с фабрикой сессий
приложения и `NoopMapper` (маппинг результата в доменные таблицы — Срез 2).
Остановка: фоновые задачи обмена → NATS → S3 → SMB → пул соединений БД; каждый шаг независим — ошибка одного
логируется и не отменяет остальные.
"""
import asyncio
import os
from collections.abc import Awaitable, Callable
from contextlib import asynccontextmanager
from datetime import UTC, datetime
from functools import partial
@ -33,7 +37,7 @@ from src.core.clients import S3Storage
from src.core.config import get_settings
from src.core.exception_handlers import register_exception_handlers
from src.core.nats import MessageHandler, NatsConnection
from src.db.db import async_session_maker
from src.db.db import async_session_maker, engine
from src.services.recognition_exchange import ExchangeTasks, NoopMapper, handle_message
settings = get_settings()
@ -118,18 +122,57 @@ def _start_exchange(nats: NatsConnection) -> ExchangeTasks:
return ExchangeTasks.start(async_session_maker, nats, settings)
def _smb_configured() -> bool:
return bool(settings.OPENBAO.SMB.SMB_HOST)
async def _register_smb() -> None:
"""Сессия SMB (шаблон) — только если задан `SMB_HOST`; вызов блокирующий, поэтому в отдельном потоке."""
smb = settings.OPENBAO.SMB
if not _smb_configured():
logger.debug("SMB не настроен (OPENBAO__SMB__SMB_HOST пуст): сессия SMB не регистрируется")
return
try:
await asyncio.to_thread(smbclient.register_session, smb.SMB_HOST, smb.SMB_LOGIN, smb.SMB_PASSWORD)
except Exception:
logger.exception("Ошибка при регистрации сессии SMB")
async def _reset_smb() -> None:
"""Закрывает кэш соединений SMB (блокирующий вызов — в отдельном потоке); без SMB ничего не делает."""
if _smb_configured():
await asyncio.to_thread(smbclient.reset_connection_cache)
async def _cleanup_step(name: str, action: Callable[[], Awaitable[object]]) -> None:
"""Один шаг остановки: исключение логируется и не мешает следующим шагам."""
try:
await action()
except Exception:
logger.exception("Ошибка при остановке приложения. step={step}", step=name)
async def _shutdown(app: FastAPI) -> None:
"""Остановка по шагам: задачи обмена (до NATS: публикатор не пишет в закрытое соединение) → NATS → S3 → SMB →
пул соединений БД (последним: задачи обмена и inbox к этому моменту остановлены)."""
exchange, nats, storage = app.state.exchange, app.state.nats, app.state.storage
if exchange is not None:
await _cleanup_step("exchange", exchange.stop)
if nats is not None:
await _cleanup_step("nats", nats.stop)
if storage:
await _cleanup_step("s3", storage.client.close_session)
await _cleanup_step("smb", _reset_smb)
await _cleanup_step("db", engine.dispose)
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.storage = None
app.state.nats = None
app.state.exchange = None
try:
smbclient.register_session(
settings.OPENBAO.SMB.SMB_HOST, settings.OPENBAO.SMB.SMB_LOGIN, settings.OPENBAO.SMB.SMB_PASSWORD
)
except Exception:
logger.exception("Ошибка при регистрации сессии SMB")
await _register_smb()
try:
app.state.storage = await _connect_s3()
@ -142,13 +185,7 @@ async def lifespan(app: FastAPI):
yield
finally:
logger.info("Приложение закрывается")
smbclient.reset_connection_cache()
if app.state.exchange is not None: # до закрытия NATS: публикатор не должен писать в закрытое соединение
await app.state.exchange.stop()
if app.state.nats is not None:
await app.state.nats.stop()
if app.state.storage:
await app.state.storage.client.close_session()
await _shutdown(app)
app = FastAPI(title="АРМ КА", version=VERSION, root_path=settings.ROOT_PATH, lifespan=lifespan)

View File

@ -22,17 +22,20 @@ class NATSSettings(BaseSettings):
class SMBSettings(BaseSettings):
SMB_HOST: str
SMB_PATH: str
SMB_LOGIN: str
SMB_PASSWORD: str
"""SMB (горячие папки ИС Сканирование, шаблон). Необязательны: ядру в Срезе 0 SMB не нужен; пустой `SMB_HOST` —
сессия SMB не регистрируется."""
SMB_HOST: str = ""
SMB_PATH: str = ""
SMB_LOGIN: str = ""
SMB_PASSWORD: str = ""
class OpenBAOSettings(BaseSettings):
DATABASE: DBSettings
MINIO: MinioSettings
NATS: NATSSettings
SMB: SMBSettings
SMB: SMBSettings = SMBSettings() # все поля необязательны (см. SMBSettings)
class ServerSettings(BaseSettings):

View File

@ -5,12 +5,14 @@
import asyncio
import importlib
import threading
from http import HTTPStatus
from types import SimpleNamespace
from typing import Any
import httpx
import pytest
from loguru import logger
from src.api.router import router
from src.core.nats import NatsConnection
@ -94,6 +96,27 @@ def _no_smb(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(app_module.smbclient, "reset_connection_cache", lambda: None)
class _FakeEngine:
"""Подмена движка БД приложения: lifespan вызывает `dispose()` последним шагом остановки (финальное ревью M-3)."""
def __init__(self, events: list[str] | None = None) -> None:
self.disposed = 0
self.events = events
async def dispose(self) -> None:
self.disposed += 1
if self.events is not None:
self.events.append("db")
@pytest.fixture(autouse=True)
def fake_engine(monkeypatch: pytest.MonkeyPatch) -> _FakeEngine:
"""Lifespan в быстрых тестах не трогает пул реального движка БД."""
engine = _FakeEngine()
monkeypatch.setattr(app_module, "engine", engine)
return engine
@pytest.fixture(autouse=True)
def exchange_loops(monkeypatch: pytest.MonkeyPatch) -> list[str]:
"""Циклы публикатора и вахтёра подменены ожиданием: быстрые тесты lifespan не ходят в БД."""
@ -300,3 +323,113 @@ async def test_lifespan_without_nats_starts_no_exchange_tasks(
await asyncio.sleep(0)
assert exchange_loops == []
assert asyncio.all_tasks() - tasks_before == set()
# --- SMB необязателен, остановка по независимым шагам (финальное ревью I-3, T4.5, M-3) -------------------------------
async def _unavailable_s3() -> None:
return None
def test_env_files_leave_smb_off():
"""В `.env.test` (и, значит, в тестах) SMB выключен: пустой SMB_HOST."""
assert app_module.settings.OPENBAO.SMB.SMB_HOST == ""
async def test_lifespan_skips_smb_when_host_empty(monkeypatch: pytest.MonkeyPatch, fake_engine: _FakeEngine):
def forbidden(*args: Any, **kwargs: Any) -> None:
raise AssertionError("SMB не настроен — вызова быть не должно")
records: list[Any] = []
handler_id = logger.add(lambda message: records.append(message.record), level="DEBUG")
monkeypatch.setattr(app_module.settings.OPENBAO.SMB, "SMB_HOST", "")
monkeypatch.setattr(app_module.smbclient, "register_session", forbidden)
monkeypatch.setattr(app_module.smbclient, "reset_connection_cache", forbidden)
monkeypatch.setattr(app_module, "_connect_s3", _unavailable_s3)
monkeypatch.setattr(app_module, "_create_nats", lambda: None)
try:
async with app_module.lifespan(app_module.app):
pass
finally:
logger.remove(handler_id)
assert [r for r in records if "SMB не настроен" in r["message"] and r["level"].name == "DEBUG"]
assert not [r for r in records if r["level"].name == "ERROR"]
assert fake_engine.disposed == 1
async def test_lifespan_registers_smb_in_worker_thread_when_configured(monkeypatch: pytest.MonkeyPatch):
"""Задан SMB_HOST: `register_session` и `reset_connection_cache` — вне потока цикла событий (`to_thread`)."""
loop_thread = threading.get_ident()
calls: list[tuple[str, Any, bool]] = []
def register_session(*args: Any, **kwargs: Any) -> None:
calls.append(("register", args, threading.get_ident() != loop_thread))
def reset_connection_cache() -> None:
calls.append(("reset", (), threading.get_ident() != loop_thread))
smb = app_module.settings.OPENBAO.SMB
monkeypatch.setattr(smb, "SMB_HOST", "smb.local")
monkeypatch.setattr(smb, "SMB_LOGIN", "user")
monkeypatch.setattr(smb, "SMB_PASSWORD", "password")
monkeypatch.setattr(app_module.smbclient, "register_session", register_session)
monkeypatch.setattr(app_module.smbclient, "reset_connection_cache", reset_connection_cache)
monkeypatch.setattr(app_module, "_connect_s3", _unavailable_s3)
monkeypatch.setattr(app_module, "_create_nats", lambda: None)
async with app_module.lifespan(app_module.app):
assert calls == [("register", ("smb.local", "user", "password"), True)]
assert calls[-1] == ("reset", (), True)
class _FailingNats(_FakeNats):
def __init__(self, events: list[str]) -> None:
super().__init__()
self.events = events
async def stop(self) -> None:
self.events.append("nats")
raise RuntimeError("подставная ошибка остановки NATS")
class _FailingS3Client:
def __init__(self, events: list[str]) -> None:
self.events = events
async def close_session(self) -> None:
self.events.append("s3")
raise RuntimeError("подставная ошибка закрытия S3")
async def test_shutdown_steps_are_independent_and_engine_disposed_last(
monkeypatch: pytest.MonkeyPatch, exchange_loops: list[str]
):
"""Ошибки `nats.stop()` и закрытия S3 не отменяют сброс SMB и `engine.dispose()`; порядок шагов сохраняется."""
events: list[str] = []
monkeypatch.setattr(app_module, "engine", _FakeEngine(events))
monkeypatch.setattr(app_module.settings.OPENBAO.SMB, "SMB_HOST", "smb.local")
monkeypatch.setattr(app_module.smbclient, "register_session", lambda *args, **kwargs: None)
monkeypatch.setattr(app_module.smbclient, "reset_connection_cache", lambda: events.append("smb"))
storage = SimpleNamespace(client=_FailingS3Client(events))
async def connect_s3() -> SimpleNamespace:
return storage
monkeypatch.setattr(app_module, "_connect_s3", connect_s3)
monkeypatch.setattr(app_module, "_create_nats", lambda: _FailingNats(events))
records: list[Any] = []
handler_id = logger.add(lambda message: records.append(message.record), level="ERROR")
try:
async with app_module.lifespan(app_module.app):
exchange = app_module.app.state.exchange
assert exchange is not None
finally:
logger.remove(handler_id)
assert all(task.done() for task in exchange.tasks)
assert events == ["nats", "s3", "smb", "db"]
failed_steps = sorted(r["extra"].get("step") for r in records)
assert failed_steps == ["nats", "s3"]

View File

@ -27,10 +27,11 @@ def test_env_test_is_read(clean_env: pytest.MonkeyPatch) -> None:
assert s.AUTH_DEV_ISSUER is True
assert s.NATS_ENABLED is False
assert s.OPENBAO.MINIO.MINIO_SECURE is False
assert s.OPENBAO.SMB.SMB_HOST == "" # SMB выключен: в логе запуска нет трейсбэка SMB
def test_defaults(clean_env: pytest.MonkeyPatch) -> None:
"""Значения по умолчанию классов настроек: env-файла нет, заданы только обязательные OPENBAO__*."""
"""Значения по умолчанию классов настроек: env-файла нет, заданы только обязательные OPENBAO__* (SMB не нужен)."""
required = {
"OPENBAO__DATABASE__DATABASE_URL": "postgresql+asyncpg://u:p@localhost:5432/d",
"OPENBAO__MINIO__MINIO_URL": "localhost:9000",
@ -38,10 +39,6 @@ def test_defaults(clean_env: pytest.MonkeyPatch) -> None:
"OPENBAO__MINIO__MINIO_ADMIN_PASSWORD": "password",
"OPENBAO__MINIO__MINIO_BUCKET": "bucket",
"OPENBAO__NATS__NATS_URL": "nats://localhost:4222",
"OPENBAO__SMB__SMB_HOST": "host",
"OPENBAO__SMB__SMB_PATH": "path",
"OPENBAO__SMB__SMB_LOGIN": "login",
"OPENBAO__SMB__SMB_PASSWORD": "password",
}
for key, value in required.items():
clean_env.setenv(key, value)
@ -59,6 +56,8 @@ def test_defaults(clean_env: pytest.MonkeyPatch) -> None:
assert s.OPENBAO.NATS.NATS_SEED == ""
assert s.OPENBAO.NATS.NATS_JWT == ""
assert s.OPENBAO.MINIO.MINIO_SECURE is True
smb = s.OPENBAO.SMB
assert (smb.SMB_HOST, smb.SMB_PATH, smb.SMB_LOGIN, smb.SMB_PASSWORD) == ("", "", "", "")
def test_empty_nats_seed_and_jwt_allowed(clean_env: pytest.MonkeyPatch) -> None:
@ -67,3 +66,4 @@ def test_empty_nats_seed_and_jwt_allowed(clean_env: pytest.MonkeyPatch) -> None:
assert s.OPENBAO.NATS.NATS_SEED == ""
assert s.OPENBAO.NATS.NATS_JWT == ""
assert s.NATS_ENABLED is True
assert s.OPENBAO.SMB.SMB_HOST == ""