Горячая папка: запись прямо в Input, как в старом АРМ; таймаут 20 мин (#26) #27
@ -83,13 +83,13 @@ class Settings(BaseSettings):
|
|||||||
SCAN_HOTFOLDER_PATH: str = "" # корень горячей папки в режиме local (каталог должен существовать)
|
SCAN_HOTFOLDER_PATH: str = "" # корень горячей папки в режиме local (каталог должен существовать)
|
||||||
SCAN_INPUT_DIR: str = "Input"
|
SCAN_INPUT_DIR: str = "Input"
|
||||||
SCAN_OUTPUT_DIR: str = "Output"
|
SCAN_OUTPUT_DIR: str = "Output"
|
||||||
SCAN_TMP_DIR: str = "Tmp" # служебный каталог временных файлов записи (рядом с Input, на той же ФС/шаре)
|
|
||||||
SCAN_POLL_SECONDS: PositiveFloat = 5 # период опроса «Исходящих»
|
SCAN_POLL_SECONDS: PositiveFloat = 5 # период опроса «Исходящих»
|
||||||
# набор *.json ответа готов, когда имена и размеры не менялись столько секунд; 0 — готов сразу (тесты)
|
# набор *.json ответа готов, когда имена и размеры не менялись столько секунд; 0 — готов сразу (тесты)
|
||||||
SCAN_SETTLE_SECONDS: NonNegativeFloat = 2
|
SCAN_SETTLE_SECONDS: NonNegativeFloat = 2
|
||||||
SCAN_TIMEOUT_SECONDS: PositiveFloat = 300 # потолок ожидания ответа ИС (дополнительно к reply_deadline запроса)
|
# Потолок ожидания ответа ИС (дополнительно к reply_deadline запроса): 20 мин, в паре со сроком ответа OCR ядра
|
||||||
|
SCAN_TIMEOUT_SECONDS: PositiveFloat = 1200
|
||||||
SCAN_MAX_INFLIGHT: PositiveInt = 4 # одновременно обрабатываемых заданий
|
SCAN_MAX_INFLIGHT: PositiveInt = 4 # одновременно обрабатываемых заданий
|
||||||
# Очистка горячей папки после прочтения набора ответа (D-2): любое удаление, включая .part в Tmp, только при true.
|
# Очистка горячей папки после прочтения набора ответа (D-2): ответ, входящий файл, .done — только при true.
|
||||||
# На SMB-шаре контура права на удаление не подтверждены — по умолчанию выключено
|
# На SMB-шаре контура права на удаление не подтверждены — по умолчанию выключено
|
||||||
SCAN_CLEANUP: bool = False
|
SCAN_CLEANUP: bool = False
|
||||||
SCAN_RULES_DIR: str = "rules" # справочники нормализатора (yaml), путь от текущего каталога (api/)
|
SCAN_RULES_DIR: str = "rules" # справочники нормализатора (yaml), путь от текущего каталога (api/)
|
||||||
|
|||||||
@ -18,7 +18,6 @@ def create_hotfolder(settings: Settings) -> HotFolder:
|
|||||||
settings.SCAN_HOTFOLDER_PATH,
|
settings.SCAN_HOTFOLDER_PATH,
|
||||||
settings.SCAN_INPUT_DIR,
|
settings.SCAN_INPUT_DIR,
|
||||||
settings.SCAN_OUTPUT_DIR,
|
settings.SCAN_OUTPUT_DIR,
|
||||||
settings.SCAN_TMP_DIR,
|
|
||||||
settle_seconds=settings.SCAN_SETTLE_SECONDS,
|
settle_seconds=settings.SCAN_SETTLE_SECONDS,
|
||||||
cleanup=settings.SCAN_CLEANUP,
|
cleanup=settings.SCAN_CLEANUP,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,17 +1,16 @@
|
|||||||
"""Горячие папки ИС Сканирование: протокол `HotFolder` и реализация в обычном каталоге (D-1, D-2, R-4, R-5).
|
"""Горячие папки ИС Сканирование: протокол `HotFolder` и реализация в обычном каталоге (D-1, D-2, R-4, R-5).
|
||||||
|
|
||||||
Файл кладётся во входящий каталог как `<job_id>.<ext>` (D-1): сначала временный `.<имя>.part` в служебном каталоге
|
Файл кладётся во входящий каталог как `<job_id>.<ext>` (D-1) сразу, открытием `wb` с перезаписью существующего — как в
|
||||||
`Tmp` (на той же ФС/шаре), затем жёсткая ссылка (без замены) во входящий (решение №17). Ответ ИС — набор файлов каталога
|
старом АРМ (решение владельца 09.10.2026, change `hotfolder-direct-write`). Ответ ИС — набор файлов каталога
|
||||||
`<output>/<job_id>/`: ИС кладёт туда все распознанные документы задания, по одному `*.json` на документ (OQ-78).
|
`<output>/<job_id>/`: ИС кладёт туда все распознанные документы задания, по одному `*.json` на документ (OQ-78).
|
||||||
`poll` отдаёт все `*.json` по возрастанию имени, когда набор готов: есть хотя бы один `*.json`, а набор имён и размеров
|
`poll` отдаёт все `*.json` по возрастанию имени, когда набор готов: есть хотя бы один `*.json`, а набор имён и размеров
|
||||||
не менялся `settle_seconds` (`SCAN_SETTLE_SECONDS`, `SettleTracker`). Имена каталогов (`Input`/`Output`/`Tmp`)
|
не менялся `settle_seconds` (`SCAN_SETTLE_SECONDS`, `SettleTracker`). Имена каталогов (`Input`/`Output`) настраиваются,
|
||||||
настраиваются, а не зашиты в код.
|
а не зашиты в код; `Tmp` — служебный каталог эмулятора (сборка набора ответа), шлюз его не использует.
|
||||||
|
|
||||||
Очистка (D-2) удаляет следы задания: ответ, входящий файл, временные файлы — при `SCAN_CLEANUP=true`.
|
Очистка (D-2) удаляет следы задания: ответ, входящий файл и его `.done` — при `SCAN_CLEANUP=true`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import contextlib
|
import contextlib
|
||||||
import errno
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
@ -29,8 +28,6 @@ _ILLEGAL_CHARS = frozenset(':<>"|?*') | frozenset(chr(code) for code in range(0x
|
|||||||
_FORBIDDEN_CHARS = _ILLEGAL_CHARS | frozenset(_PATH_SEPARATORS)
|
_FORBIDDEN_CHARS = _ILLEGAL_CHARS | frozenset(_PATH_SEPARATORS)
|
||||||
# Расширение входящего файла — суффикс после последней точки, в нижнем регистре, 1–16 символов ASCII [0-9a-z]
|
# Расширение входящего файла — суффикс после последней точки, в нижнем регистре, 1–16 символов ASCII [0-9a-z]
|
||||||
_EXTENSION_RE = re.compile(r"[0-9a-z]{1,16}")
|
_EXTENSION_RE = re.compile(r"[0-9a-z]{1,16}")
|
||||||
# errno `os.link`, означающие «ФС не умеет жёсткие ссылки» (запасной путь записи — `os.replace`)
|
|
||||||
_NO_HARD_LINKS = (errno.EPERM, errno.EXDEV, errno.ENOTSUP, errno.EOPNOTSUPP)
|
|
||||||
|
|
||||||
|
|
||||||
def validate_name(name: str) -> None:
|
def validate_name(name: str) -> None:
|
||||||
@ -171,7 +168,8 @@ class LocalHotFolder:
|
|||||||
"""Горячая папка в обычном каталоге: dev, compose-том, unit-тесты.
|
"""Горячая папка в обычном каталоге: dev, compose-том, unit-тесты.
|
||||||
|
|
||||||
`root`, `input_dir`, `output_dir`, `tmp_dir`, `answer_dir` и `answer_path` открыты намеренно (R-4): эмулятор
|
`root`, `input_dir`, `output_dir`, `tmp_dir`, `answer_dir` и `answer_path` открыты намеренно (R-4): эмулятор
|
||||||
ИС Сканирование работает с ними обычными операциями файловой системы (листинг входящих, `.done`, запись ответа).
|
ИС Сканирование работает с ними обычными операциями файловой системы (листинг входящих, `.done`, запись ответа);
|
||||||
|
`tmp_dir` нужен только эмулятору (сборка набора ответа), `put` его не использует.
|
||||||
`settle_seconds` и `clock` (монотонные часы) — готовность набора ответа (`SettleTracker`).
|
`settle_seconds` и `clock` (монотонные часы) — готовность набора ответа (`SettleTracker`).
|
||||||
`cleanup_enabled` (D-2) — очистка включена при `SCAN_CLEANUP`.
|
`cleanup_enabled` (D-2) — очистка включена при `SCAN_CLEANUP`.
|
||||||
"""
|
"""
|
||||||
@ -204,76 +202,22 @@ class LocalHotFolder:
|
|||||||
return self.answer_dir(job_id) / f"{job_id}.json"
|
return self.answer_dir(job_id) / f"{job_id}.json"
|
||||||
|
|
||||||
def put(self, name: str, data: bytes) -> None:
|
def put(self, name: str, data: bytes) -> None:
|
||||||
"""Атомарная запись без замены: `.<name>.part` в `tmp_dir`, затем жёсткая ссылка в `input_dir/<name>` и
|
"""Записать `input_dir/<name>` сразу (`wb`, перезапись существующего, как в старом АРМ — решение владельца
|
||||||
удаление `.part` как часть успешной публикации (R-8). Назначение уже есть — файл считается доставленным
|
09.10.2026). `Input` создаётся без родителей, корень — нет (нет корня — `FileNotFoundError`). При ошибке записи
|
||||||
(повтор задания, R-17): ничего не пишется, `.part` снимается только при включённой очистке. Корень не
|
недописанный файл снимается только при включённой очистке (R-3), ошибка снятия подавляется."""
|
||||||
создаётся (нет корня — `FileNotFoundError`)."""
|
|
||||||
validate_name(name)
|
validate_name(name)
|
||||||
self.tmp_dir.mkdir(exist_ok=True)
|
|
||||||
self.input_dir.mkdir(exist_ok=True)
|
self.input_dir.mkdir(exist_ok=True)
|
||||||
part = self.tmp_dir / f".{name}.part"
|
|
||||||
target = self.input_dir / name
|
target = self.input_dir / name
|
||||||
if target.exists():
|
|
||||||
# R-17: `.part` от прошлой публикации может быть жёсткой ссылкой на этот файл (удаление после публикации
|
|
||||||
# подавляется, R-8, OQ-144) — писать и даже удалять его незачем, файл уже доставлен
|
|
||||||
self._drop_part(part)
|
|
||||||
return
|
|
||||||
# `.part` создаётся заново (№32): остаток от прерванной записи не должен ронять задание, поэтому ошибка его
|
|
||||||
# удаления подавляется; если он всё же остался, `_open_part` не даст записать «поверх» ссылки на чужой файл
|
|
||||||
with contextlib.suppress(OSError):
|
|
||||||
part.unlink(missing_ok=True)
|
|
||||||
fd = self._open_part(part)
|
|
||||||
try:
|
try:
|
||||||
with os.fdopen(fd, "wb") as handle:
|
target.write_bytes(data)
|
||||||
handle.write(data)
|
|
||||||
except BaseException:
|
except BaseException:
|
||||||
self._drop_part(part)
|
self._drop_partial(target)
|
||||||
raise
|
|
||||||
try:
|
|
||||||
os.link(part, target) # без замены: существующее назначение даёт FileExistsError
|
|
||||||
except FileExistsError:
|
|
||||||
self._drop_part(part)
|
|
||||||
return
|
|
||||||
except OSError as err:
|
|
||||||
# ФС без жёстких ссылок (некоторые тома Docker/сетевые монтирования): запасной путь через replace с
|
|
||||||
# проверкой назначения (окно гонки крошечное, повтор задания с тем же job_id редок). Только для самой
|
|
||||||
# линковки: ошибка записи выше поднимается как есть
|
|
||||||
if err.errno in _NO_HARD_LINKS and not target.exists():
|
|
||||||
self._replace_part(part, target)
|
|
||||||
return
|
|
||||||
self._drop_part(part)
|
|
||||||
raise
|
|
||||||
except BaseException:
|
|
||||||
self._drop_part(part)
|
|
||||||
raise
|
|
||||||
# завершение успешной записи (как rename в старой версии), не управляется флагом очистки:
|
|
||||||
# файл уже доставлен, ошибка удаления .part её не должна отменить (R-8)
|
|
||||||
with contextlib.suppress(OSError):
|
|
||||||
part.unlink(missing_ok=True)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _open_part(part: Path) -> int:
|
|
||||||
"""Открыть `.part` на запись: новый файл (`O_EXCL`). Если старый не удалился (`FileExistsError`) — усечь его
|
|
||||||
только когда это обычный файл без других ссылок (`st_nlink == 1`); ссылку на опубликованный файл не трогаем,
|
|
||||||
исключение поднимается как есть (R-17)."""
|
|
||||||
try:
|
|
||||||
return os.open(part, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
|
|
||||||
except FileExistsError:
|
|
||||||
if os.lstat(part).st_nlink != 1:
|
|
||||||
raise
|
|
||||||
return os.open(part, os.O_WRONLY | os.O_TRUNC)
|
|
||||||
|
|
||||||
def _replace_part(self, part: Path, target: Path) -> None:
|
|
||||||
try:
|
|
||||||
os.replace(part, target)
|
|
||||||
except BaseException:
|
|
||||||
self._drop_part(part)
|
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def _drop_part(self, part: Path) -> None:
|
def _drop_partial(self, path: Path) -> None:
|
||||||
if self.cleanup_enabled:
|
if self.cleanup_enabled:
|
||||||
with contextlib.suppress(OSError):
|
with contextlib.suppress(OSError):
|
||||||
part.unlink(missing_ok=True)
|
path.unlink(missing_ok=True)
|
||||||
|
|
||||||
def cleanup(self, job_id: str, name: str) -> bool:
|
def cleanup(self, job_id: str, name: str) -> bool:
|
||||||
if not self.cleanup_enabled:
|
if not self.cleanup_enabled:
|
||||||
@ -282,7 +226,7 @@ class LocalHotFolder:
|
|||||||
folder = self.answer_dir(job_id)
|
folder = self.answer_dir(job_id)
|
||||||
if folder.is_dir():
|
if folder.is_dir():
|
||||||
shutil.rmtree(folder)
|
shutil.rmtree(folder)
|
||||||
for path in (self.input_dir / name, self.input_dir / f"{name}.done", self.tmp_dir / f".{name}.part"):
|
for path in (self.input_dir / name, self.input_dir / f"{name}.done"):
|
||||||
try:
|
try:
|
||||||
os.unlink(path)
|
os.unlink(path)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
Настоящий lifespan приложения (`create_app` → S3, NATS, воркер, `OcrNormalizer` со справочниками `api/rules/`) и
|
Настоящий lifespan приложения (`create_app` → S3, NATS, воркер, `OcrNormalizer` со справочниками `api/rules/`) и
|
||||||
эмулятор ИС Сканирование (`src.scan_emulator.run` в потоке, `SCAN_EMULATOR_DEFAULT` пуст) работают в процессе
|
эмулятор ИС Сканирование (`src.scan_emulator.run` в потоке, `SCAN_EMULATOR_DEFAULT` пуст) работают в процессе
|
||||||
теста; NATS 127.0.0.1:4222 и MinIO 127.0.0.1:9000 — из `api/.env.test` (`make up`), горячая папка — tmp-каталог.
|
теста; NATS 127.0.0.1:4222 и MinIO 127.0.0.1:9000 — из `api/.env.test` (`make up`), горячая папка — tmp-каталог.
|
||||||
Входящий файл — `<job_id>.pdf` (расширение `original_name` в нижнем регистре, `.part` живёт в `Tmp`), пару фикстур
|
Входящий файл — `<job_id>.pdf` (расширение `original_name` в нижнем регистре, пишется прямо в `Input`), пару фикстур
|
||||||
эмулятор находит по sha256 содержимого: в его каталоге фикстур лежат PDF и ответы (реальные `aep_*` из
|
эмулятор находит по sha256 содержимого: в его каталоге фикстур лежат PDF и ответы (реальные `aep_*` из
|
||||||
`fixtures/ocr` и синтетический `ok_doc`).
|
`fixtures/ocr` и синтетический `ok_doc`).
|
||||||
|
|
||||||
@ -16,7 +16,7 @@
|
|||||||
3. `aep_6.pdf` (один успешный файл) → `needs_check`, `result_s3` с двумя документами (`BALANCE`, `INCOME`);
|
3. `aep_6.pdf` (один успешный файл) → `needs_check`, `result_s3` с двумя документами (`BALANCE`, `INCOME`);
|
||||||
4. `ok_doc.pdf` (синтетический успешный ответ в форме реальных) → `needs_check` (редакция `fsbu4` пуста) с `result_s3`;
|
4. `ok_doc.pdf` (синтетический успешный ответ в форме реальных) → `needs_check` (редакция `fsbu4` пуста) с `result_s3`;
|
||||||
5. `unknown.pdf` (содержимого нет в фикстурах) → ack, затем `INVALID_DOCUMENT` эмулятора;
|
5. `unknown.pdf` (содержимого нет в фикстурах) → ack, затем `INVALID_DOCUMENT` эмулятора;
|
||||||
6. `aep_6.pdf` при `SCAN_CLEANUP=true` → после результата `Input/` и `Output/<job_id>/` пусты, `Tmp/` без `.part`.
|
6. `aep_6.pdf` при `SCAN_CLEANUP=true` → после результата `Input/` и `Output/<job_id>/` пусты.
|
||||||
Каждый конверт проверяется схемой `recognition-result-message`, `service.name` и `Nats-Msg-Id`; файл результата
|
Каждый конверт проверяется схемой `recognition-result-message`, `service.name` и `Nats-Msg-Id`; файл результата
|
||||||
из MinIO — схемой `recognition-result`; склеенный сырой ответ в MinIO содержит ровно те файлы, что лежат в каталоге
|
из MinIO — схемой `recognition-result`; склеенный сырой ответ в MinIO содержит ровно те файлы, что лежат в каталоге
|
||||||
ответа, в порядке возрастания имени, с телами байт в байт; входящий файл совпадает с объектом S3. Готовность набора
|
ответа, в порядке возрастания имени, с телами байт в байт; входящий файл совпадает с объектом S3. Готовность набора
|
||||||
@ -178,10 +178,6 @@ class Roundtrip:
|
|||||||
def input_dir(self) -> Path:
|
def input_dir(self) -> Path:
|
||||||
return self.root / self.settings.SCAN_INPUT_DIR
|
return self.root / self.settings.SCAN_INPUT_DIR
|
||||||
|
|
||||||
@property
|
|
||||||
def tmp_dir(self) -> Path:
|
|
||||||
return self.root / self.settings.SCAN_TMP_DIR
|
|
||||||
|
|
||||||
def answer_dir(self, job_id: str) -> Path:
|
def answer_dir(self, job_id: str) -> Path:
|
||||||
return self.root / self.settings.SCAN_OUTPUT_DIR / job_id
|
return self.root / self.settings.SCAN_OUTPUT_DIR / job_id
|
||||||
|
|
||||||
@ -260,12 +256,6 @@ class Roundtrip:
|
|||||||
def inbox(self, job_id: str) -> list[str]:
|
def inbox(self, job_id: str) -> list[str]:
|
||||||
return sorted(p.name for p in self.input_dir.iterdir() if p.name.startswith(job_id))
|
return sorted(p.name for p in self.input_dir.iterdir() if p.name.startswith(job_id))
|
||||||
|
|
||||||
def stray_parts(self) -> list[str]:
|
|
||||||
"""Временные `.part` записи во входящий каталог: после успешной публикации их в `Tmp` нет (решение №26)."""
|
|
||||||
return (
|
|
||||||
sorted(p.name for p in self.tmp_dir.iterdir() if p.name.endswith(".part")) if self.tmp_dir.is_dir() else []
|
|
||||||
)
|
|
||||||
|
|
||||||
async def run(self, original_name: str, data: bytes, *, cleanup: bool = False) -> tuple[dict[str, Any], Envelope]:
|
async def run(self, original_name: str, data: bytes, *, cleanup: bool = False) -> tuple[dict[str, Any], Envelope]:
|
||||||
"""Запрос → ack → итоговый конверт; общие проверки сквозного сценария. Возвращает запрос и итог."""
|
"""Запрос → ack → итоговый конверт; общие проверки сквозного сценария. Возвращает запрос и итог."""
|
||||||
body = await self.request(original_name, data)
|
body = await self.request(original_name, data)
|
||||||
@ -283,8 +273,7 @@ class Roundtrip:
|
|||||||
"""Сырой ответ в MinIO — склейка ответа эмулятора (D-4); входящий файл `<job_id>.pdf` = объект S3.
|
"""Сырой ответ в MinIO — склейка ответа эмулятора (D-4); входящий файл `<job_id>.pdf` = объект S3.
|
||||||
|
|
||||||
Без очистки (`SCAN_CLEANUP=false`) каталог ответа и входящий файл (с суффиксом `.done` эмулятора) остаются; при
|
Без очистки (`SCAN_CLEANUP=false`) каталог ответа и входящий файл (с суффиксом `.done` эмулятора) остаются; при
|
||||||
очистке (`SCAN_CLEANUP=true`) во входящем каталоге пусто, каталога `Output/<job_id>/` нет. `.part` в `Tmp` нет
|
очистке (`SCAN_CLEANUP=true`) во входящем каталоге пусто, каталога `Output/<job_id>/` нет.
|
||||||
в обоих режимах.
|
|
||||||
"""
|
"""
|
||||||
job_id = body["job_id"]
|
job_id = body["job_id"]
|
||||||
assert result.body["file_id"] == body["file_id"]
|
assert result.body["file_id"] == body["file_id"]
|
||||||
@ -297,7 +286,6 @@ class Roundtrip:
|
|||||||
assert entry["response"]["file_uid"] == job_id
|
assert entry["response"]["file_uid"] == job_id
|
||||||
stat = await self.s3.client.stat_object(self.s3.bucket, f"raw/{job_id}.json")
|
stat = await self.s3.client.stat_object(self.s3.bucket, f"raw/{job_id}.json")
|
||||||
assert stat.content_type == JSON_CONTENT_TYPE
|
assert stat.content_type == JSON_CONTENT_TYPE
|
||||||
assert self.stray_parts() == []
|
|
||||||
if cleanup:
|
if cleanup:
|
||||||
assert list(self.input_dir.iterdir()) == []
|
assert list(self.input_dir.iterdir()) == []
|
||||||
assert not self.answer_dir(job_id).exists()
|
assert not self.answer_dir(job_id).exists()
|
||||||
@ -393,7 +381,7 @@ def _start_emulator(stack: AsyncExitStack, settings: Settings, root: Path, fixtu
|
|||||||
hotfolder_path=root,
|
hotfolder_path=root,
|
||||||
input_dir=settings.SCAN_INPUT_DIR,
|
input_dir=settings.SCAN_INPUT_DIR,
|
||||||
output_dir=settings.SCAN_OUTPUT_DIR,
|
output_dir=settings.SCAN_OUTPUT_DIR,
|
||||||
tmp_dir=settings.SCAN_TMP_DIR,
|
tmp_dir="Tmp", # служебный каталог эмулятора; у шлюза его нет (change hotfolder-direct-write)
|
||||||
fixtures_dir=fixtures,
|
fixtures_dir=fixtures,
|
||||||
delay=EMULATOR_DELAY,
|
delay=EMULATOR_DELAY,
|
||||||
poll_interval=0.05,
|
poll_interval=0.05,
|
||||||
@ -650,4 +638,3 @@ async def test_cleanup_enabled_leaves_input_and_answer_empty(roundtrip_cleanup:
|
|||||||
assert [entry["name"] for entry in raw["files"]] == [f"{job_id}.json"]
|
assert [entry["name"] for entry in raw["files"]] == [f"{job_id}.json"]
|
||||||
assert list(roundtrip_cleanup.input_dir.iterdir()) == []
|
assert list(roundtrip_cleanup.input_dir.iterdir()) == []
|
||||||
assert not roundtrip_cleanup.answer_dir(job_id).exists()
|
assert not roundtrip_cleanup.answer_dir(job_id).exists()
|
||||||
assert roundtrip_cleanup.stray_parts() == []
|
|
||||||
|
|||||||
@ -58,7 +58,6 @@ async def make_env(tmp_path: Path) -> AsyncIterator[EnvFactory]:
|
|||||||
root,
|
root,
|
||||||
settings.SCAN_INPUT_DIR,
|
settings.SCAN_INPUT_DIR,
|
||||||
settings.SCAN_OUTPUT_DIR,
|
settings.SCAN_OUTPUT_DIR,
|
||||||
settings.SCAN_TMP_DIR,
|
|
||||||
settle_seconds=settings.SCAN_SETTLE_SECONDS,
|
settle_seconds=settings.SCAN_SETTLE_SECONDS,
|
||||||
cleanup=settings.SCAN_CLEANUP,
|
cleanup=settings.SCAN_CLEANUP,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -29,10 +29,10 @@ def test_defaults_local_mode_without_smb_and_nats_credentials(monkeypatch, minim
|
|||||||
assert s.SCAN_HOTFOLDER_MODE == "local"
|
assert s.SCAN_HOTFOLDER_MODE == "local"
|
||||||
assert s.SCAN_INPUT_DIR == "Input"
|
assert s.SCAN_INPUT_DIR == "Input"
|
||||||
assert s.SCAN_OUTPUT_DIR == "Output"
|
assert s.SCAN_OUTPUT_DIR == "Output"
|
||||||
assert s.SCAN_TMP_DIR == "Tmp"
|
assert not hasattr(s, "SCAN_TMP_DIR") # служебный каталог только у эмулятора (change hotfolder-direct-write)
|
||||||
assert s.SCAN_SETTLE_SECONDS == 2
|
assert s.SCAN_SETTLE_SECONDS == 2
|
||||||
assert s.SCAN_POLL_SECONDS == 5
|
assert s.SCAN_POLL_SECONDS == 5
|
||||||
assert s.SCAN_TIMEOUT_SECONDS == 300
|
assert s.SCAN_TIMEOUT_SECONDS == 1200
|
||||||
assert s.SCAN_MAX_INFLIGHT == 4
|
assert s.SCAN_MAX_INFLIGHT == 4
|
||||||
assert s.OPENBAO.SMB.SMB_HOST == ""
|
assert s.OPENBAO.SMB.SMB_HOST == ""
|
||||||
assert s.OPENBAO.NATS.NATS_SEED == ""
|
assert s.OPENBAO.NATS.NATS_SEED == ""
|
||||||
@ -80,6 +80,16 @@ def test_negative_settle_seconds_rejected(monkeypatch):
|
|||||||
get_settings()
|
get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_scan_tmp_dir_is_ignored(monkeypatch, minimal_env_file):
|
||||||
|
"""Старый `.env` с `SCAN_TMP_DIR` (до change hotfolder-direct-write) не ломает старт: `extra="ignore"`."""
|
||||||
|
_clean_env(monkeypatch)
|
||||||
|
monkeypatch.setenv("SCAN_TMP_DIR", "Tmp")
|
||||||
|
|
||||||
|
s = get_settings()
|
||||||
|
|
||||||
|
assert not hasattr(s, "SCAN_TMP_DIR")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("settle", ["10", "11"])
|
@pytest.mark.parametrize("settle", ["10", "11"])
|
||||||
def test_settle_not_less_than_timeout_rejected(monkeypatch, settle):
|
def test_settle_not_less_than_timeout_rejected(monkeypatch, settle):
|
||||||
"""M-4: набор не может настояться за срок, который короче или равен settle — каждое задание давало бы TIMEOUT."""
|
"""M-4: набор не может настояться за срок, который короче или равен settle — каждое задание давало бы TIMEOUT."""
|
||||||
@ -156,7 +166,7 @@ def test_env_test_file_loads_for_tests():
|
|||||||
assert s.SCAN_HOTFOLDER_PATH == "./.hotfolder-test"
|
assert s.SCAN_HOTFOLDER_PATH == "./.hotfolder-test"
|
||||||
assert s.SCAN_POLL_SECONDS == 0.1
|
assert s.SCAN_POLL_SECONDS == 0.1
|
||||||
assert s.SCAN_TIMEOUT_SECONDS == 5
|
assert s.SCAN_TIMEOUT_SECONDS == 5
|
||||||
assert s.SCAN_TMP_DIR == "Tmp"
|
assert not hasattr(s, "SCAN_TMP_DIR")
|
||||||
assert s.SCAN_SETTLE_SECONDS == 0 # R-23: тесты воркера не ждут стабилизации набора
|
assert s.SCAN_SETTLE_SECONDS == 0 # R-23: тесты воркера не ждут стабилизации набора
|
||||||
assert s.OPENBAO.MINIO.MINIO_URL == "127.0.0.1:9000"
|
assert s.OPENBAO.MINIO.MINIO_URL == "127.0.0.1:9000"
|
||||||
assert s.OPENBAO.MINIO.MINIO_SECURE is False
|
assert s.OPENBAO.MINIO.MINIO_SECURE is False
|
||||||
@ -172,7 +182,7 @@ def test_env_example_loads(monkeypatch):
|
|||||||
|
|
||||||
assert s.SCAN_HOTFOLDER_MODE == "local"
|
assert s.SCAN_HOTFOLDER_MODE == "local"
|
||||||
assert s.SCAN_INPUT_DIR == "Input"
|
assert s.SCAN_INPUT_DIR == "Input"
|
||||||
assert s.SCAN_TMP_DIR == "Tmp"
|
assert not hasattr(s, "SCAN_TMP_DIR")
|
||||||
assert s.SCAN_SETTLE_SECONDS == 2
|
assert s.SCAN_SETTLE_SECONDS == 2
|
||||||
assert s.OPENBAO.MINIO.MINIO_BUCKET == "arm-ka"
|
assert s.OPENBAO.MINIO.MINIO_BUCKET == "arm-ka"
|
||||||
assert s.OPENBAO.NATS.NATS_JWT == ""
|
assert s.OPENBAO.NATS.NATS_JWT == ""
|
||||||
|
|||||||
@ -20,7 +20,6 @@ def _local_settings(root: Path) -> Settings:
|
|||||||
"SCAN_HOTFOLDER_MODE": "local",
|
"SCAN_HOTFOLDER_MODE": "local",
|
||||||
"SCAN_HOTFOLDER_PATH": str(root),
|
"SCAN_HOTFOLDER_PATH": str(root),
|
||||||
"SCAN_INPUT_DIR": "In",
|
"SCAN_INPUT_DIR": "In",
|
||||||
"SCAN_TMP_DIR": "Служебный",
|
|
||||||
"SCAN_SETTLE_SECONDS": 1.5,
|
"SCAN_SETTLE_SECONDS": 1.5,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@ -45,7 +44,7 @@ def test_local_mode_builds_local_hotfolder(tmp_path: Path) -> None:
|
|||||||
assert hf.root == tmp_path
|
assert hf.root == tmp_path
|
||||||
assert hf.input_dir == tmp_path / "In"
|
assert hf.input_dir == tmp_path / "In"
|
||||||
assert hf.output_dir == tmp_path / "Output"
|
assert hf.output_dir == tmp_path / "Output"
|
||||||
assert hf.tmp_dir == tmp_path / "Служебный"
|
assert hf.tmp_dir == tmp_path / "Tmp" # служебный каталог эмулятора, шлюз его не использует
|
||||||
assert hf.settle_seconds == 1.5
|
assert hf.settle_seconds == 1.5
|
||||||
assert hf.healthy() is True
|
assert hf.healthy() is True
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
"""Горячая папка в обычном каталоге (spec image-recognition-gateway, D-1, R-4/R-5, slice-0-skeleton).
|
"""Горячая папка в обычном каталоге (spec image-recognition-gateway, D-1, R-4/R-5, slice-0-skeleton).
|
||||||
|
|
||||||
Быстрые тесты на `tmp_path`: атомарная запись входящего файла через служебный каталог `Tmp`, опрос набора ответов ИС
|
Быстрые тесты на `tmp_path`: запись входящего файла прямо в `Input` с перезаписью, опрос набора ответов ИС
|
||||||
с готовностью по стабилизации (settle, часы подменены), проверка здоровья, валидация имён.
|
с готовностью по стабилизации (settle, часы подменены), проверка здоровья, валидация имён.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@ -41,6 +41,17 @@ def _settled(tmp_path: Path, settle: float = 2) -> tuple[LocalHotFolder, FakeClo
|
|||||||
return LocalHotFolder(tmp_path, settle_seconds=settle, clock=clock), clock
|
return LocalHotFolder(tmp_path, settle_seconds=settle, clock=clock), clock
|
||||||
|
|
||||||
|
|
||||||
|
def _failing_write(self: Path, data: bytes) -> int:
|
||||||
|
"""Недописанный файл: на диск попадают первые два байта, затем ошибка ввода-вывода."""
|
||||||
|
with open(self, "wb") as handle:
|
||||||
|
handle.write(data[:2])
|
||||||
|
raise OSError(errno.ENOSPC, "диск полон")
|
||||||
|
|
||||||
|
|
||||||
|
def _failing_unlink(self: Path, missing_ok: bool = False) -> None:
|
||||||
|
raise PermissionError(errno.EACCES, "нет прав")
|
||||||
|
|
||||||
|
|
||||||
class TestInboxName:
|
class TestInboxName:
|
||||||
def test_job_id_and_lowercase_extension(self) -> None:
|
def test_job_id_and_lowercase_extension(self) -> None:
|
||||||
job_id = _job_id()
|
job_id = _job_id()
|
||||||
@ -57,34 +68,12 @@ class TestInboxName:
|
|||||||
inbox_name("job", original)
|
inbox_name("job", original)
|
||||||
|
|
||||||
|
|
||||||
class TestPutWithoutReplace:
|
|
||||||
def test_existing_destination_is_kept(self, tmp_path: Path) -> None:
|
|
||||||
hf = LocalHotFolder(tmp_path)
|
|
||||||
hf.put("job.pdf", b"first")
|
|
||||||
hf.put("job.pdf", b"second")
|
|
||||||
assert (hf.input_dir / "job.pdf").read_bytes() == b"first"
|
|
||||||
# R-17: назначение есть — повтор ничего не пишет; прежнего `.part` после успешной публикации нет
|
|
||||||
assert not (hf.tmp_dir / ".job.pdf.part").exists()
|
|
||||||
|
|
||||||
def test_existing_destination_part_removed_when_cleanup_enabled(self, tmp_path: Path) -> None:
|
|
||||||
hf = LocalHotFolder(tmp_path, cleanup=True)
|
|
||||||
hf.put("job.pdf", b"first")
|
|
||||||
hf.put("job.pdf", b"second")
|
|
||||||
assert (hf.input_dir / "job.pdf").read_bytes() == b"first"
|
|
||||||
assert not (hf.tmp_dir / ".job.pdf.part").exists()
|
|
||||||
|
|
||||||
def test_no_temp_files_in_input(self, tmp_path: Path) -> None:
|
|
||||||
hf = LocalHotFolder(tmp_path)
|
|
||||||
hf.put("job.pdf", b"x")
|
|
||||||
assert [p.name for p in hf.input_dir.iterdir()] == ["job.pdf"]
|
|
||||||
assert not (hf.tmp_dir / ".job.pdf.part").exists() # после успешной публикации .part нет
|
|
||||||
|
|
||||||
|
|
||||||
class TestCleanup:
|
class TestCleanup:
|
||||||
def _fill(self, hf: LocalHotFolder, job_id: str) -> None:
|
def _fill(self, hf: LocalHotFolder, job_id: str) -> None:
|
||||||
hf.put(f"{job_id}.pdf", b"x")
|
hf.put(f"{job_id}.pdf", b"x")
|
||||||
(hf.input_dir / f"{job_id}.pdf.done").write_bytes(b"")
|
(hf.input_dir / f"{job_id}.pdf.done").write_bytes(b"")
|
||||||
(hf.tmp_dir / f".{job_id}.pdf.part").write_bytes(b"orphan")
|
hf.tmp_dir.mkdir(exist_ok=True)
|
||||||
|
(hf.tmp_dir / f".{job_id}.pdf.part").write_bytes(b"legacy") # остаток прежних версий шлюза — не наш
|
||||||
_write_answer(hf, job_id, b"{}")
|
_write_answer(hf, job_id, b"{}")
|
||||||
_write_answer(hf, job_id, b"{}", name=f"{job_id}_ofr.json")
|
_write_answer(hf, job_id, b"{}", name=f"{job_id}_ofr.json")
|
||||||
(hf.answer_dir(job_id) / "readme.txt").write_bytes(b"keep")
|
(hf.answer_dir(job_id) / "readme.txt").write_bytes(b"keep")
|
||||||
@ -104,7 +93,7 @@ class TestCleanup:
|
|||||||
assert hf.cleanup(job_id, f"{job_id}.pdf") is True
|
assert hf.cleanup(job_id, f"{job_id}.pdf") is True
|
||||||
assert not (hf.input_dir / f"{job_id}.pdf").exists()
|
assert not (hf.input_dir / f"{job_id}.pdf").exists()
|
||||||
assert not (hf.input_dir / f"{job_id}.pdf.done").exists()
|
assert not (hf.input_dir / f"{job_id}.pdf.done").exists()
|
||||||
assert not (hf.tmp_dir / f".{job_id}.pdf.part").exists()
|
assert (hf.tmp_dir / f".{job_id}.pdf.part").exists() # Tmp очистка не трогает
|
||||||
assert not hf.answer_dir(job_id).exists() # каталог удалён вместе с readme.txt
|
assert not hf.answer_dir(job_id).exists() # каталог удалён вместе с readme.txt
|
||||||
assert hf.cleanup(job_id, f"{job_id}.pdf") is True # повтор — не ошибка
|
assert hf.cleanup(job_id, f"{job_id}.pdf") is True # повтор — не ошибка
|
||||||
|
|
||||||
@ -181,60 +170,42 @@ class TestPut:
|
|||||||
hf.put(name, data)
|
hf.put(name, data)
|
||||||
assert (tmp_path / "Input" / name).read_bytes() == data
|
assert (tmp_path / "Input" / name).read_bytes() == data
|
||||||
|
|
||||||
def test_leaves_no_part_files(self, tmp_path: Path) -> None:
|
def test_creates_input_dir_only(self, tmp_path: Path) -> None:
|
||||||
hf = LocalHotFolder(tmp_path)
|
hf = LocalHotFolder(tmp_path)
|
||||||
job_id = _job_id()
|
|
||||||
name = inbox_name(job_id, "aep_5.pdf")
|
|
||||||
hf.put(name, b"data")
|
|
||||||
assert [p.name for p in hf.input_dir.iterdir()] == [name]
|
|
||||||
assert list(hf.tmp_dir.iterdir()) == []
|
|
||||||
|
|
||||||
def test_part_is_written_in_tmp_not_in_input(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
"""OQ-79: во время записи во входящем каталоге нет временного файла — он в `Tmp/`, затем link в `Input`."""
|
|
||||||
hf = LocalHotFolder(tmp_path)
|
|
||||||
job_id = _job_id()
|
|
||||||
name = inbox_name(job_id, "aep_5.pdf")
|
|
||||||
seen: dict[str, Any] = {}
|
|
||||||
real_link = os.link
|
|
||||||
|
|
||||||
def spy(src: Any, dst: Any) -> None:
|
|
||||||
seen["src"], seen["dst"] = Path(src), Path(dst)
|
|
||||||
seen["input"] = sorted(p.name for p in hf.input_dir.iterdir())
|
|
||||||
seen["tmp"] = sorted(p.name for p in hf.tmp_dir.iterdir())
|
|
||||||
seen["part"] = Path(src).read_bytes()
|
|
||||||
real_link(src, dst)
|
|
||||||
|
|
||||||
monkeypatch.setattr("src.gateway.hotfolder.os.link", spy)
|
|
||||||
hf.put(name, b"data")
|
|
||||||
assert seen == {
|
|
||||||
"src": hf.tmp_dir / f".{name}.part",
|
|
||||||
"dst": hf.input_dir / name,
|
|
||||||
"input": [],
|
|
||||||
"tmp": [f".{name}.part"],
|
|
||||||
"part": b"data",
|
|
||||||
}
|
|
||||||
assert [p.name for p in hf.input_dir.iterdir()] == [name]
|
|
||||||
assert list(hf.tmp_dir.iterdir()) == []
|
|
||||||
|
|
||||||
def test_creates_input_and_tmp_dirs_only(self, tmp_path: Path) -> None:
|
|
||||||
hf = LocalHotFolder(tmp_path)
|
|
||||||
assert not hf.input_dir.exists()
|
|
||||||
assert not hf.tmp_dir.exists()
|
|
||||||
hf.put("a.pdf", b"1")
|
hf.put("a.pdf", b"1")
|
||||||
assert hf.input_dir.is_dir()
|
assert sorted(p.name for p in tmp_path.iterdir()) == ["Input"] # Tmp не создаётся
|
||||||
assert hf.tmp_dir.is_dir()
|
|
||||||
assert not hf.output_dir.exists()
|
|
||||||
|
|
||||||
def test_existing_tmp_dir_is_fine(self, tmp_path: Path) -> None:
|
def test_existing_file_is_overwritten(self, tmp_path: Path) -> None:
|
||||||
(tmp_path / "Tmp").mkdir()
|
"""Как в старом АРМ (решение владельца 09.10.2026): повтор задания перезаписывает входящий файл."""
|
||||||
LocalHotFolder(tmp_path).put("a.pdf", b"1")
|
hf = LocalHotFolder(tmp_path, settle_seconds=0)
|
||||||
assert (tmp_path / "Input" / "a.pdf").read_bytes() == b"1"
|
hf.put("job.pdf", b"first, longer")
|
||||||
|
hf.put("job.pdf", b"second")
|
||||||
|
assert (hf.input_dir / "job.pdf").read_bytes() == b"second"
|
||||||
|
|
||||||
def test_custom_tmp_dir_name(self, tmp_path: Path) -> None:
|
def test_write_failure_keeps_partial_file_when_cleanup_disabled(
|
||||||
hf = LocalHotFolder(tmp_path, tmp_dir="Служебный")
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
hf.put("a.pdf", b"1")
|
) -> None:
|
||||||
assert (tmp_path / "Служебный").is_dir()
|
hf = LocalHotFolder(tmp_path, settle_seconds=0)
|
||||||
assert not (tmp_path / "Tmp").exists()
|
monkeypatch.setattr(Path, "write_bytes", _failing_write)
|
||||||
|
with pytest.raises(OSError, match="диск полон"):
|
||||||
|
hf.put("job.pdf", b"data")
|
||||||
|
assert (hf.input_dir / "job.pdf").read_bytes() == b"da"
|
||||||
|
|
||||||
|
def test_write_failure_removes_partial_file_when_cleanup_enabled(
|
||||||
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
hf = LocalHotFolder(tmp_path, settle_seconds=0, cleanup=True)
|
||||||
|
monkeypatch.setattr(Path, "write_bytes", _failing_write)
|
||||||
|
with pytest.raises(OSError, match="диск полон"):
|
||||||
|
hf.put("job.pdf", b"data")
|
||||||
|
assert not (hf.input_dir / "job.pdf").exists()
|
||||||
|
|
||||||
|
def test_remove_failure_does_not_mask_write_error(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
hf = LocalHotFolder(tmp_path, settle_seconds=0, cleanup=True)
|
||||||
|
monkeypatch.setattr(Path, "write_bytes", _failing_write)
|
||||||
|
monkeypatch.setattr(Path, "unlink", _failing_unlink)
|
||||||
|
with pytest.raises(OSError, match="диск полон"):
|
||||||
|
hf.put("job.pdf", b"data")
|
||||||
|
|
||||||
def test_existing_input_dir_is_fine(self, tmp_path: Path) -> None:
|
def test_existing_input_dir_is_fine(self, tmp_path: Path) -> None:
|
||||||
(tmp_path / "Input").mkdir()
|
(tmp_path / "Input").mkdir()
|
||||||
@ -285,227 +256,6 @@ class TestPut:
|
|||||||
hf.put(name, b"x")
|
hf.put(name, b"x")
|
||||||
assert sorted(p.name for p in hf.input_dir.iterdir()) == sorted(names)
|
assert sorted(p.name for p in hf.input_dir.iterdir()) == sorted(names)
|
||||||
|
|
||||||
def test_cleans_part_file_on_failure(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
"""При cleanup=True и ошибке link: .part удаляется."""
|
|
||||||
hf = LocalHotFolder(tmp_path, cleanup=True)
|
|
||||||
|
|
||||||
def boom(src: Any, dst: Any) -> None:
|
|
||||||
raise OSError("диск недоступен")
|
|
||||||
|
|
||||||
monkeypatch.setattr("src.gateway.hotfolder.os.link", boom)
|
|
||||||
with pytest.raises(OSError, match="диск недоступен"):
|
|
||||||
hf.put("a.pdf", b"data")
|
|
||||||
assert list(hf.input_dir.iterdir()) == []
|
|
||||||
assert list(hf.tmp_dir.iterdir()) == []
|
|
||||||
|
|
||||||
def test_keeps_part_file_on_failure_when_cleanup_disabled(
|
|
||||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
||||||
) -> None:
|
|
||||||
"""При cleanup=False и ошибке link: .part остаётся в Tmp (T11.1)."""
|
|
||||||
hf = LocalHotFolder(tmp_path, cleanup=False)
|
|
||||||
|
|
||||||
def boom(src: Any, dst: Any) -> None:
|
|
||||||
raise OSError("диск недоступен")
|
|
||||||
|
|
||||||
monkeypatch.setattr("src.gateway.hotfolder.os.link", boom)
|
|
||||||
with pytest.raises(OSError, match="диск недоступен"):
|
|
||||||
hf.put("a.pdf", b"data")
|
|
||||||
assert list(hf.input_dir.iterdir()) == []
|
|
||||||
assert [p.name for p in hf.tmp_dir.iterdir()] == [".a.pdf.part"]
|
|
||||||
|
|
||||||
def test_successful_put_despite_final_unlink_failure(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
"""Успешный link, но ошибка в завершающем unlink .part (R-8): put НЕ падает, файл доставлен (T11.2)."""
|
|
||||||
hf = LocalHotFolder(tmp_path)
|
|
||||||
|
|
||||||
unlink_calls = []
|
|
||||||
|
|
||||||
real_unlink = os.unlink
|
|
||||||
|
|
||||||
def unlink_with_failure(path: Any) -> None:
|
|
||||||
unlink_calls.append(path)
|
|
||||||
if str(path).endswith(".part"):
|
|
||||||
raise PermissionError("удаление запрещено")
|
|
||||||
real_unlink(path)
|
|
||||||
|
|
||||||
monkeypatch.setattr("src.gateway.hotfolder.os.unlink", unlink_with_failure)
|
|
||||||
hf.put("a.pdf", b"data") # НЕ падает несмотря на ошибку unlink
|
|
||||||
assert (hf.input_dir / "a.pdf").read_bytes() == b"data"
|
|
||||||
assert (hf.tmp_dir / ".a.pdf.part").exists() # .part не удалён из-за ошибки
|
|
||||||
assert unlink_calls # попытка удаления была
|
|
||||||
|
|
||||||
def test_cleanup_failure_does_not_mask_original_error(
|
|
||||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
||||||
) -> None:
|
|
||||||
"""Ошибка удаления `.part` на пути ошибки link подавляется (T11.1); исходное исключение проходит."""
|
|
||||||
hf = LocalHotFolder(tmp_path, cleanup=True)
|
|
||||||
|
|
||||||
def boom(src: Any, dst: Any) -> None:
|
|
||||||
raise OSError("диск недоступен")
|
|
||||||
|
|
||||||
def unlink_fails(self: Path, missing_ok: bool = False) -> None:
|
|
||||||
raise PermissionError("удаление .part запрещено")
|
|
||||||
|
|
||||||
monkeypatch.setattr("src.gateway.hotfolder.os.link", boom)
|
|
||||||
monkeypatch.setattr(Path, "unlink", unlink_fails)
|
|
||||||
with pytest.raises(OSError, match="диск недоступен"):
|
|
||||||
hf.put("a.pdf", b"data")
|
|
||||||
|
|
||||||
|
|
||||||
class TestPartIsCreatedFresh:
|
|
||||||
"""Решение №32: `.part` создаётся заново (удаление старого + `O_EXCL`), fallback — только вокруг `os.link`."""
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("cleanup", [False, True])
|
|
||||||
def test_existing_destination_is_not_touched_by_repeat(self, tmp_path: Path, cleanup: bool) -> None:
|
|
||||||
"""R-17: назначение есть, `.part` — жёсткая ссылка на него: байты не меняются, put не падает."""
|
|
||||||
hf = LocalHotFolder(tmp_path, cleanup=cleanup)
|
|
||||||
hf.put("job.pdf", b"first")
|
|
||||||
published = hf.input_dir / "job.pdf"
|
|
||||||
stale = hf.tmp_dir / ".job.pdf.part"
|
|
||||||
os.link(published, stale)
|
|
||||||
hf.put("job.pdf", b"different data")
|
|
||||||
assert published.read_bytes() == b"first"
|
|
||||||
assert stale.exists() is (not cleanup) # очистка снимает `.part`, иначе он остаётся как был
|
|
||||||
if not cleanup:
|
|
||||||
assert stale.read_bytes() == b"first" and published.stat().st_nlink == 2
|
|
||||||
|
|
||||||
def test_existing_destination_with_undeletable_part_does_not_fail(
|
|
||||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
||||||
) -> None:
|
|
||||||
hf = LocalHotFolder(tmp_path) # очистка выключена: удаление `.part` вообще не пытается
|
|
||||||
hf.put("job.pdf", b"first")
|
|
||||||
os.link(hf.input_dir / "job.pdf", hf.tmp_dir / ".job.pdf.part")
|
|
||||||
|
|
||||||
def deny(self: Path, missing_ok: bool = False) -> None:
|
|
||||||
raise PermissionError("удаление запрещено")
|
|
||||||
|
|
||||||
monkeypatch.setattr(Path, "unlink", deny)
|
|
||||||
hf.put("job.pdf", b"second")
|
|
||||||
assert (hf.input_dir / "job.pdf").read_bytes() == b"first"
|
|
||||||
|
|
||||||
def test_undeletable_stale_part_with_single_link_is_truncated(
|
|
||||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
||||||
) -> None:
|
|
||||||
"""Назначения нет, старый `.part` не удаляется, но это обычный файл (nlink == 1): put успешен."""
|
|
||||||
hf = LocalHotFolder(tmp_path)
|
|
||||||
hf.tmp_dir.mkdir()
|
|
||||||
stale = hf.tmp_dir / ".job.pdf.part"
|
|
||||||
stale.write_bytes(b"old and longer content")
|
|
||||||
|
|
||||||
def deny(self: Path, missing_ok: bool = False) -> None:
|
|
||||||
raise PermissionError("удаление запрещено")
|
|
||||||
|
|
||||||
monkeypatch.setattr(Path, "unlink", deny)
|
|
||||||
hf.put("job.pdf", b"new")
|
|
||||||
assert (hf.input_dir / "job.pdf").read_bytes() == b"new"
|
|
||||||
|
|
||||||
def test_undeletable_stale_part_with_other_links_is_not_written(
|
|
||||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
||||||
) -> None:
|
|
||||||
"""Назначения нет, старый `.part` не удаляется и имеет nlink == 2 (синтетически): запись не идёт, ошибка не
|
|
||||||
маскируется, второй файл не изменён."""
|
|
||||||
hf = LocalHotFolder(tmp_path)
|
|
||||||
hf.tmp_dir.mkdir()
|
|
||||||
other = tmp_path / "other.bin"
|
|
||||||
other.write_bytes(b"precious")
|
|
||||||
os.link(other, hf.tmp_dir / ".job.pdf.part")
|
|
||||||
|
|
||||||
def deny(self: Path, missing_ok: bool = False) -> None:
|
|
||||||
raise PermissionError("удаление запрещено")
|
|
||||||
|
|
||||||
monkeypatch.setattr(Path, "unlink", deny)
|
|
||||||
with pytest.raises(FileExistsError):
|
|
||||||
hf.put("job.pdf", b"new")
|
|
||||||
assert other.read_bytes() == b"precious"
|
|
||||||
assert not (hf.input_dir / "job.pdf").exists()
|
|
||||||
|
|
||||||
def test_stale_part_is_replaced_before_publication(self, tmp_path: Path) -> None:
|
|
||||||
hf = LocalHotFolder(tmp_path)
|
|
||||||
hf.tmp_dir.mkdir()
|
|
||||||
(hf.tmp_dir / ".job.pdf.part").write_bytes(b"old and longer content")
|
|
||||||
hf.put("job.pdf", b"new")
|
|
||||||
assert (hf.input_dir / "job.pdf").read_bytes() == b"new"
|
|
||||||
assert not (hf.tmp_dir / ".job.pdf.part").exists()
|
|
||||||
|
|
||||||
def test_part_is_opened_exclusively(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
hf = LocalHotFolder(tmp_path)
|
|
||||||
flags: list[int] = []
|
|
||||||
real_open = os.open
|
|
||||||
|
|
||||||
def spy(path: Any, flag: int, mode: int = 0o777, **kwargs: Any) -> int:
|
|
||||||
flags.append(flag)
|
|
||||||
return real_open(path, flag, mode, **kwargs)
|
|
||||||
|
|
||||||
monkeypatch.setattr("src.gateway.hotfolder.os.open", spy)
|
|
||||||
hf.put("job.pdf", b"x")
|
|
||||||
assert flags and flags[0] & os.O_EXCL and flags[0] & os.O_CREAT
|
|
||||||
|
|
||||||
def test_write_error_propagates_as_itself(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
"""EPERM записи не превращается в `FileNotFoundError` от `os.replace` несуществующего `.part`."""
|
|
||||||
hf = LocalHotFolder(tmp_path, cleanup=True)
|
|
||||||
|
|
||||||
class FailingFile:
|
|
||||||
def __enter__(self) -> "FailingFile":
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __exit__(self, *exc: object) -> None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def write(self, data: bytes) -> int:
|
|
||||||
raise OSError(errno.EPERM, "запись запрещена")
|
|
||||||
|
|
||||||
monkeypatch.setattr("src.gateway.hotfolder.os.fdopen", lambda fd, mode: (os.close(fd), FailingFile())[1])
|
|
||||||
with pytest.raises(OSError, match="запись запрещена") as caught:
|
|
||||||
hf.put("job.pdf", b"data")
|
|
||||||
assert caught.value.errno == errno.EPERM and not isinstance(caught.value, FileNotFoundError)
|
|
||||||
assert list(hf.input_dir.iterdir()) == [] and list(hf.tmp_dir.iterdir()) == []
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("code", [errno.EPERM, errno.EXDEV, errno.ENOTSUP, errno.EOPNOTSUPP])
|
|
||||||
def test_fallback_to_replace_when_link_unsupported(
|
|
||||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, code: int
|
|
||||||
) -> None:
|
|
||||||
hf = LocalHotFolder(tmp_path)
|
|
||||||
|
|
||||||
def no_links(src: Any, dst: Any) -> None:
|
|
||||||
raise OSError(code, "жёсткие ссылки не поддерживаются")
|
|
||||||
|
|
||||||
monkeypatch.setattr("src.gateway.hotfolder.os.link", no_links)
|
|
||||||
hf.put("job.pdf", b"data")
|
|
||||||
assert (hf.input_dir / "job.pdf").read_bytes() == b"data"
|
|
||||||
assert list(hf.tmp_dir.iterdir()) == [] # `.part` стал назначением
|
|
||||||
|
|
||||||
def test_no_fallback_when_destination_appeared_during_link(
|
|
||||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
||||||
) -> None:
|
|
||||||
"""Назначение появилось между проверкой и линковкой, а `os.link` дал EPERM: `os.replace` не затирает."""
|
|
||||||
hf = LocalHotFolder(tmp_path, cleanup=True)
|
|
||||||
hf.input_dir.mkdir()
|
|
||||||
|
|
||||||
def no_links(src: Any, dst: Any) -> None:
|
|
||||||
Path(dst).write_bytes(b"first") # файл успел появиться (параллельный повтор того же задания)
|
|
||||||
raise OSError(errno.EPERM, "жёсткие ссылки не поддерживаются")
|
|
||||||
|
|
||||||
monkeypatch.setattr("src.gateway.hotfolder.os.link", no_links)
|
|
||||||
with pytest.raises(OSError, match="жёсткие ссылки"):
|
|
||||||
hf.put("job.pdf", b"second")
|
|
||||||
assert (hf.input_dir / "job.pdf").read_bytes() == b"first"
|
|
||||||
|
|
||||||
def test_replace_failure_after_link_fallback_propagates(
|
|
||||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
||||||
) -> None:
|
|
||||||
hf = LocalHotFolder(tmp_path, cleanup=True)
|
|
||||||
|
|
||||||
def no_links(src: Any, dst: Any) -> None:
|
|
||||||
raise OSError(errno.EPERM, "жёсткие ссылки не поддерживаются")
|
|
||||||
|
|
||||||
def broken_replace(src: Any, dst: Any) -> None:
|
|
||||||
raise OSError(errno.EIO, "замена не удалась")
|
|
||||||
|
|
||||||
monkeypatch.setattr("src.gateway.hotfolder.os.link", no_links)
|
|
||||||
monkeypatch.setattr("src.gateway.hotfolder.os.replace", broken_replace)
|
|
||||||
with pytest.raises(OSError, match="замена не удалась"):
|
|
||||||
hf.put("job.pdf", b"data")
|
|
||||||
assert list(hf.tmp_dir.iterdir()) == [] # при очистке `.part` снят
|
|
||||||
|
|
||||||
|
|
||||||
class TestPoll:
|
class TestPoll:
|
||||||
"""`poll` — все `*.json` каталога `<output>/<job_id>/` по имени, когда набор настоялся `settle_seconds`."""
|
"""`poll` — все `*.json` каталога `<output>/<job_id>/` по имени, когда набор настоялся `settle_seconds`."""
|
||||||
|
|||||||
@ -953,13 +953,14 @@ class TestSlice3a:
|
|||||||
assert "Очистка горячей папки не удалась" in warnings[0] and job_id in warnings[0] and "нет прав" in warnings[0]
|
assert "Очистка горячей папки не удалась" in warnings[0] and job_id in warnings[0] and "нет прав" in warnings[0]
|
||||||
|
|
||||||
async def test_existing_input_waits_for_answer(self, make_env: EnvFactory) -> None:
|
async def test_existing_input_waits_for_answer(self, make_env: EnvFactory) -> None:
|
||||||
"""Review Focus 4: входящий файл уже лежит (повтор задания), ответа нет — TIMEOUT, файл не перезаписан."""
|
"""Входящий файл уже лежит (повтор задания), ответа нет — TIMEOUT; файл перезаписан объектом S3, как в старом
|
||||||
|
АРМ (change hotfolder-direct-write, решение владельца 09.10.2026)."""
|
||||||
env = await make_env()
|
env = await make_env()
|
||||||
body = env.request(deadline_s=0.3)
|
body = env.request(deadline_s=0.3)
|
||||||
env.local.put(f"{body['job_id']}.pdf", b"already there")
|
env.local.put(f"{body['job_id']}.pdf", b"already there")
|
||||||
result = await env.run(body)
|
result = await env.run(body)
|
||||||
assert result["error"]["code"] == "TIMEOUT"
|
assert result["error"]["code"] == "TIMEOUT"
|
||||||
assert (env.local.input_dir / f"{body['job_id']}.pdf").read_bytes() == b"already there"
|
assert (env.local.input_dir / f"{body['job_id']}.pdf").read_bytes() == PDF_BYTES
|
||||||
assert env.inbox() == [f"{body['job_id']}.pdf"]
|
assert env.inbox() == [f"{body['job_id']}.pdf"]
|
||||||
|
|
||||||
async def test_ocr_normalizer_produces_documents(self, make_env: EnvFactory) -> None:
|
async def test_ocr_normalizer_produces_documents(self, make_env: EnvFactory) -> None:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user