From 85f4e9c8471ff9b07eecbd01888debf45d63a5d4 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 9 Oct 2026 16:22:07 +0400 Subject: [PATCH 1/6] =?UTF-8?q?openspec:=20change=20hotfolder-direct-write?= =?UTF-8?q?=20=E2=80=94=20=D0=B7=D0=B0=D0=BF=D0=B8=D1=81=D1=8C=20=D0=BF?= =?UTF-8?q?=D1=80=D1=8F=D0=BC=D0=BE=20=D0=B2=20Input,=20=D0=BA=D0=B0=D0=BA?= =?UTF-8?q?=20=D0=B2=20=D1=81=D1=82=D0=B0=D1=80=D0=BE=D0=BC=20=D0=90=D0=A0?= =?UTF-8?q?=D0=9C=20(#26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../hotfolder-direct-write/.openspec.yaml | 2 + .../changes/hotfolder-direct-write/design.md | 58 ++++++++++++++++ .../hotfolder-direct-write/proposal.md | 43 ++++++++++++ .../specs/image-recognition-gateway/spec.md | 67 +++++++++++++++++++ .../changes/hotfolder-direct-write/tasks.md | 18 +++++ 5 files changed, 188 insertions(+) create mode 100644 openspec/changes/hotfolder-direct-write/.openspec.yaml create mode 100644 openspec/changes/hotfolder-direct-write/design.md create mode 100644 openspec/changes/hotfolder-direct-write/proposal.md create mode 100644 openspec/changes/hotfolder-direct-write/specs/image-recognition-gateway/spec.md create mode 100644 openspec/changes/hotfolder-direct-write/tasks.md diff --git a/openspec/changes/hotfolder-direct-write/.openspec.yaml b/openspec/changes/hotfolder-direct-write/.openspec.yaml new file mode 100644 index 0000000..8126432 --- /dev/null +++ b/openspec/changes/hotfolder-direct-write/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-09 diff --git a/openspec/changes/hotfolder-direct-write/design.md b/openspec/changes/hotfolder-direct-write/design.md new file mode 100644 index 0000000..4d7e198 --- /dev/null +++ b/openspec/changes/hotfolder-direct-write/design.md @@ -0,0 +1,58 @@ +## Context + +Запись входящего файла сейчас — двухфазная: `Tmp/.<имя>.part`, затем rename (SMB) / жёсткая ссылка (local) в `Input` +без замены (решения №17, №26, №32, №35 Среза 3a). Старый АРМ писал файл прямо в `Input` и в контуре работал. Владелец +09.10.2026 выбрал повторить старый АРМ и перезаписывать существующий файл. + +## Goals / Non-Goals + +**Goals:** прямая запись в `Input` в обоих режимах; меньше требований к правам на шаре; срок ожидания ответа 20 мин. + +**Non-Goals:** см. proposal. + +## Decisions + +### D-1. Прямая запись с усечением (решение владельца 09.10.2026) + +`SmbHotFolder.put`: `smbclient.open_file(\<имя>, mode="wb", **credentials)` и `write`. `LocalHotFolder.put`: +`open(/<имя>, "wb")` (`Path.write_bytes`). Существующий файл перезаписывается. Повторная запись в рамках одного +процесса по-прежнему исключена дедупликацией по `job_id` в воркере (требование «Подтверждение запроса до обработки»); +перезапись возможна только при повторе задания ядром в другом процессе. + +### D-2. Каталог `Input` создаётся, как раньше + +Перед записью `Input` создаётся без родителей, «уже есть» и отказ в правах при существующем каталоге — не ошибка +(`_ensure_dir` в `smb`, `mkdir(exist_ok=True)` в `local`); корень не создаётся. Старый АРМ каталог не создавал, но +терпимое создание поведения в контуре не меняет, а dev-режим и тесты на нём держатся. + +### D-3. `Tmp` остаётся только у эмулятора + +`SCAN_TMP_DIR` удаляется из настроек шлюза (`Settings`, `extra="ignore"` — старое значение в `.env` не ломает старт). +Эмулятор читает свою переменную `SCAN_TMP_DIR` (`EmulatorSettings`) и собирает набор ответа через +`LocalHotFolder.tmp_dir`, поэтому параметр `tmp_dir` у `LocalHotFolder` остаётся, но `put` его не использует. + +### D-4. Срок ожидания 20 мин + +`SCAN_TIMEOUT_SECONDS` по умолчанию 1200. Эффективный дедлайн — `min(reply_deadline, старт + SCAN_TIMEOUT_SECONDS)`, +поэтому arm-core поднимает срок ответа OCR до 20 мин отдельным PR (RSHB/arm-core#87). + +### D-5. Ошибка записи + +При исключении во время открытия/записи `put` снимает недописанный `/<имя>` только при включённой очистке +(ошибка снятия подавляется, исходное исключение поднимается) — R-3. + +## Risks / Trade-offs + +- ИС может увидеть недописанный файл (OQ-79) → смягчение: монопольное открытие `smbclient` на время записи; так же + работал старый АРМ. Откат — вернуть двухфазную запись из истории ветки. +- Ошибка посреди записи оставляет обрезанный файл в `Input` → удаляется только при `SCAN_CLEANUP=true` (как `.part` + раньше, D-5); иначе остаётся, конверт `RECOGNITION_FAILED`. + +## Decisions made without discussion + +| # | Дата | Решение | Обоснование | Цена ошибки | +|---|---|---|---|---| +| R-1 | 2026-10-09 | Режим `local` тоже пишет прямо в `Input` (не только `smb`) | Одинаковая семантика режимов, один набор тестов; эмулятор пишет/читает локально мгновенно | Эмулятор может прочитать недописанный файл — на локальной ФС практически невозможно; откат — вернуть `.part` в `local` | +| R-2 | 2026-10-09 | `Input` по-прежнему создаётся перед записью (D-2), хотя старый АРМ этого не делал | Отказ в правах на `mkdir` при существующем каталоге уже не ошибка; dev и тесты на этом держатся | Лишний вызов `mkdir` на шаре на каждое задание | +| R-3 | 2026-10-09 | Ошибка записи: недописанный файл в `Input` удаляется только при `SCAN_CLEANUP=true` (D-5) | Требование «при `SCAN_CLEANUP=false` ничего не удалять» (OQ-144) сохраняется | Обрезанный файл остаётся в `Input`, ИС может его распознать с ошибкой; ядро получит `RECOGNITION_FAILED` от шлюза раньше | +| R-4 | 2026-10-09 | `SCAN_TMP_DIR` удаляется из шлюза, у эмулятора остаётся (D-3) | Шлюзу каталог больше не нужен; эмулятору нужен для атомарной сборки ответа | Нет: `extra="ignore"` — старые `.env` стартуют | diff --git a/openspec/changes/hotfolder-direct-write/proposal.md b/openspec/changes/hotfolder-direct-write/proposal.md new file mode 100644 index 0000000..c805bf5 --- /dev/null +++ b/openspec/changes/hotfolder-direct-write/proposal.md @@ -0,0 +1,43 @@ +## Why + +Решение владельца 09.10.2026: запись входящего файла в горячую папку ИС Сканирование — «как в старом АРМ», где она +работала в контуре: `smbclient.open_file('/Input/<имя>', 'wb')` прямо во входящий каталог +(`reference-old-arm/api/src/core/clients.py:111`). Нынешняя схема (временный `Tmp/.<имя>.part` + rename без замены, +OQ-79, OQ-137) требует от учётной записи шары каталога `Tmp`, прав на его создание и на переименование и в контуре не +проверялась. Там же владелец решил: срок ожидания ответа OCR — 20 минут. +Issue — RSHB/arm-scan-gateway#26. Вне среза `09-delivery-plan.md` (доработка Среза 3a по эксплуатации). + +## What Changes + +- `put` (режимы `smb` и `local`): файл `.` пишется сразу во входящий каталог, открытием на запись с + усечением (`wb`); существующий файл **перезаписывается** (как в старом АРМ). Временного файла, каталога `Tmp` и + переименования больше нет. +- Настройка шлюза `SCAN_TMP_DIR` удаляется; параметр `tmp_dir` у `SmbHotFolder` удаляется. У `LocalHotFolder` он остаётся + только для эмулятора (сборка набора ответа, переменная эмулятора `SCAN_TMP_DIR` не меняется). +- Очистка (`SCAN_CLEANUP=true`) больше не удаляет `Tmp/...part`. +- `SCAN_TIMEOUT_SECONDS` по умолчанию 300 → 1200 (20 мин) — в паре с `reply_deadline` OCR 20 мин в arm-core + (RSHB/arm-core#87). +- README, `CLAUDE.md`, `api/.env.example`, тесты — под новую семантику. + +## Non-goals + +- Протокол ответа (`Output//*.json`, стабилизация набора, OQ-78) не меняется. +- Эмулятор ИС не меняется: он по-прежнему пропускает `*.part`/`*.tmp`/dot-файлы и собирает ответ через свой `Tmp`. +- Аутентификация SMB не меняется: `OPENBAO__SMB__SMB_LOGIN`/`SMB_PASSWORD` уже передаются в сессию и в каждый вызов. +- Таймаут вызовов `smbclient` (соединение ~60 с по умолчанию библиотеки) не трогаем. +- Таблицы DBML не затрагиваются. + +## Capabilities + +### New Capabilities + +### Modified Capabilities +- `image-recognition-gateway`: запись входящего файла без временного файла и с перезаписью; очистка без `Tmp`. + +## Impact + +- ASSUMPTION(OQ-79): ИС Сканирование не берёт файл, пока шлюз держит его открытым на запись (`smbclient.open_file` + по умолчанию открывает монопольно), — так работал старый АРМ. Если подтвердится обратное, вернуть запись через + временный файл. +- Учётной записи шары больше не нужны права на `Tmp` и на переименование; нужна запись в `Input`. +- Остатки `Tmp/.*.part` от прежних версий шлюз больше не трогает — убрать вручную. diff --git a/openspec/changes/hotfolder-direct-write/specs/image-recognition-gateway/spec.md b/openspec/changes/hotfolder-direct-write/specs/image-recognition-gateway/spec.md new file mode 100644 index 0000000..62d5643 --- /dev/null +++ b/openspec/changes/hotfolder-direct-write/specs/image-recognition-gateway/spec.md @@ -0,0 +1,67 @@ +## MODIFIED Requirements + +### Requirement: Передача файла в ИС Сканирование через горячие папки +После подтверждения сервис SHALL прочитать исходник из S3 (`s3.bucket/s3.key`) и записать его во входящий каталог как +`.`, где `` — расширение `original_name` запроса в нижнем регистре; запрос без расширения SHALL +завершаться конвертом `RECOGNITION_FAILED` без записи в горячую папку. Файл SHALL записываться сразу во входящий каталог +открытием на запись с усечением, как в старом АРМ (решение владельца 09.10.2026): временных файлов, служебного каталога +и переименования нет; если `/.` уже существует, он SHALL быть перезаписан содержимым объекта S3. +Затем сервис SHALL опрашивать исходящий каталог `//` каждые `SCAN_POLL_SECONDS` до +`min(reply_deadline, старт + SCAN_TIMEOUT_SECONDS)` (`SCAN_TIMEOUT_SECONDS` по умолчанию 1200). ИС кладёт в этот каталог +все распознанные документы задания — по одному `*.json` на документ, набор появляется целиком и больше не меняется; +пустой или отсутствующий каталог означает ожидание. Ответ SHALL считаться готовым, когда в каталоге есть хотя бы один +`*.json`, а набор имён и размеров `*.json` не менялся `SCAN_SETTLE_SECONDS` (по умолчанию 2); готовый набор SHALL +включать **все** `*.json` каталога по возрастанию имени, остальные файлы игнорируются. + +#### Scenario: Файл положен во входящий каталог +- **WHEN** получен запрос для `aep_5.PDF` с `job_id = 3f1c…` +- **THEN** во входящем каталоге появляется `3f1c….pdf`, байт в байт равный объекту S3; каталог `Tmp/` не создаётся, временных файлов нет + +#### Scenario: Назначение уже существует +- **WHEN** запрос для `aep_5.pdf` с `job_id = 3f1c…` обрабатывается, а `Input/3f1c….pdf` уже лежит с другим содержимым (задание повторено ядром после потери ack в другом процессе) +- **THEN** `Input/3f1c….pdf` перезаписан и байт в байт равен объекту S3, задание продолжается опросом `Output/3f1c…/`, конверт `RECOGNITION_FAILED` не публикуется + +#### Scenario: Запрос без расширения +- **WHEN** получен запрос с `original_name = "scan"` без точки +- **THEN** опубликован конверт `error`, `error.code = RECOGNITION_FAILED`, сообщение о расширении; во входящем каталоге файла нет + +#### Scenario: Ответ из одного файла +- **WHEN** в `Output/3f1c…/` появляется один `*.json` и набор не меняется `SCAN_SETTLE_SECONDS` +- **THEN** опрос прекращается не позднее чем через `SCAN_SETTLE_SECONDS` + `SCAN_POLL_SECONDS` после первого опроса, увидевшего окончательный набор (отсчёт стабилизации — от этого опроса, а не от времени изменения файла), ответ из одного файла передаётся дальше по конвейеру + +#### Scenario: Ответ из нескольких файлов +- **WHEN** в `Output/3f1c…/` лежат `3f1c….json` и `3f1c…_ofr.json`, посторонний файл `readme.txt`, и набор не меняется `SCAN_SETTLE_SECONDS` +- **THEN** дальше по конвейеру передаются оба `*.json` по возрастанию имени, `readme.txt` игнорируется + +#### Scenario: Набор ещё пишется +- **WHEN** размер файла или число файлов в `Output/3f1c…/` менялось менее `SCAN_SETTLE_SECONDS` назад +- **THEN** ответ не считается готовым, опрос продолжается до дедлайна + +#### Scenario: Ошибка посреди записи +- **WHEN** запись `Input/3f1c….pdf` прервалась ошибкой ввода-вывода +- **THEN** опубликован конверт `RECOGNITION_FAILED`; недописанный файл удалён только при `SCAN_CLEANUP=true`, при `false` остаётся на месте + +### Requirement: Очистка горячей папки под флагом +При `SCAN_CLEANUP=true` после того, как готовый набор ответа прочитан и склеен в `raw/{job_id}.json` (независимо от +исхода нормализации), сервис SHALL удалить всё содержимое `//` (json-файлы ответа и любые +прочие) и сам каталог ``, входящий файл `/.` и его вариант `/..done`, +если они существуют. При `SCAN_CLEANUP=false` (по умолчанию) сервис MUST NOT удалять ничего, включая недописанный при +ошибке записи входящий файл. При `TIMEOUT`, `FILE_READ_ERROR`, `RECOGNITION_FAILED` записи очистка MUST NOT выполняться +(при ошибке записи и `SCAN_CLEANUP=true` снимается только недописанный входящий файл). Ошибка удаления SHALL быть +записана в лог одной строкой на задание и MUST NOT менять конверт, `metrics.warnings` и `/healthcheck`. + +#### Scenario: Очистка включена +- **WHEN** `SCAN_CLEANUP=true`, ответ на `3f1c….pdf` прочитан из `Output/3f1c…/` +- **THEN** после публикации конверта в горячей папке нет `Output/3f1c…/`, `Input/3f1c….pdf` и `Input/3f1c….pdf.done` + +#### Scenario: Очистка выключена +- **WHEN** `SCAN_CLEANUP=false`, ответ прочитан +- **THEN** все файлы остаются на месте, сервис ничего не удаляет и не пишет в лог о очистке (решение №27) + +#### Scenario: Нет прав на удаление +- **WHEN** `SCAN_CLEANUP=true`, удаление `Output/3f1c…/3f1c….json` завершается `PermissionError` +- **THEN** конверт опубликован с тем же статусом, что и без очистки; в логе одна строка об ошибке удаления; `/healthcheck.hotfolder = ok` + +#### Scenario: Таймаут не чистит +- **WHEN** `SCAN_CLEANUP=true`, ответ не появился до дедлайна +- **THEN** опубликован `TIMEOUT`, входящий файл и каталог `Output//` (если есть) не тронуты diff --git a/openspec/changes/hotfolder-direct-write/tasks.md b/openspec/changes/hotfolder-direct-write/tasks.md new file mode 100644 index 0000000..9e4b52e --- /dev/null +++ b/openspec/changes/hotfolder-direct-write/tasks.md @@ -0,0 +1,18 @@ +## 1. Тесты (scan-gateway, `api/tests/`) + +- [ ] 1.1 `unit/test_smb_hotfolder.py`: `put` пишет `open_file(\<имя>, "wb")` без `Tmp`/`rename`; существующий + файл перезаписывается; ошибка записи — снятие файла только при `cleanup=True`; `cleanup` не трогает `.part` +- [ ] 1.2 `unit/test_local_hotfolder.py`: то же для `LocalHotFolder`; `Tmp` не создаётся `put` +- [ ] 1.3 `unit/test_config.py`, `unit/test_hotfolder_factory.py`: нет `SCAN_TMP_DIR` у шлюза, `SCAN_TIMEOUT_SECONDS` + по умолчанию 1200; интеграционные `test_roundtrip.py` — без ожиданий про `Tmp/.part` + +## 2. Реализация (scan-gateway, `api/src/`) + +- [ ] 2.1 `gateway/smb.py`: `put` — прямая запись (D-1, D-2, D-5), убрать `tmp_dir`, `.part` из `cleanup` +- [ ] 2.2 `gateway/hotfolder.py`: `LocalHotFolder.put` — прямая запись; `tmp_dir` только для эмулятора (D-3) +- [ ] 2.3 `core/config.py`, `gateway/__init__.py`: убрать `SCAN_TMP_DIR` у шлюза; `SCAN_TIMEOUT_SECONDS = 1200` + +## 3. Документация + +- [ ] 3.1 `README.md`, `CLAUDE.md`, `api/.env.example` — прямая запись, `SCAN_TMP_DIR` только у эмулятора, таймаут 1200 +- [ ] 3.2 `make check-full` -- 2.47.2 From 7ec3dd49fd6ad9bfc794daf48ac982c36b69f717 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 9 Oct 2026 16:28:31 +0400 Subject: [PATCH 2/6] plan: hotfolder-direct-write (#26) Co-Authored-By: Claude Opus 5.5 --- .../2026-10-09-hotfolder-direct-write.md | 359 ++++++++++++++++++ 1 file changed, 359 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-09-hotfolder-direct-write.md diff --git a/docs/superpowers/plans/2026-10-09-hotfolder-direct-write.md b/docs/superpowers/plans/2026-10-09-hotfolder-direct-write.md new file mode 100644 index 0000000..ad24b24 --- /dev/null +++ b/docs/superpowers/plans/2026-10-09-hotfolder-direct-write.md @@ -0,0 +1,359 @@ +# Запись в горячую папку прямо в Input — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans (план из 3 задач, ≤5 — Native по +> `~/.claude/CLAUDE.md`). Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** `put` пишет `.` сразу в `Input` (`wb`, перезапись), без `Tmp/.part` и rename; `SCAN_TMP_DIR` +уходит из шлюза; `SCAN_TIMEOUT_SECONDS` по умолчанию 1200. + +**Architecture:** Меняются только `SmbHotFolder.put/cleanup` и `LocalHotFolder.put/cleanup`; протокол `HotFolder`, +воркер и эмулятор не меняются. `LocalHotFolder.tmp_dir` остаётся для эмулятора. + +**Tech Stack:** Python 3.11, smbprotocol (`smbclient`), pytest, ruff, mypy. + +**Spec:** `openspec/changes/hotfolder-direct-write/` (proposal, design D-1…D-5, R-1…R-4, specs/image-recognition-gateway) + +Рабочий каталог: `/Users/andrey.epifancev/projects/awp-ca/.wt/smb-direct-write/scan-gateway` (ветка +`feat/26-hotfolder-direct-write`). Git — `git -C /Users/andrey.epifancev/projects/awp-ca/.wt/smb-direct-write/scan-gateway`. +`pytest` — из `api/` (`.venv/bin/pytest`), `make` — из корня репозитория. + +## Global Constraints + +- Запись: `smbclient.open_file(\<имя>, mode="wb", **credentials)` / `open(/<имя>, "wb")`; существующий файл перезаписывается. +- Временных файлов, `Tmp` и rename у шлюза нет; `Input` создаётся терпимо (`_ensure_dir` / `mkdir(exist_ok=True)`), корень — нет. +- Ошибка записи: недописанный `/<имя>` снимается только при `cleanup=True`; ошибка снятия подавляется, поднимается исходная. +- При `SCAN_CLEANUP=false` ничего не удаляется; `cleanup` удаляет `Output//`, `Input/<имя>`, `Input/<имя>.done` — и больше ничего. +- `SCAN_TIMEOUT_SECONDS: PositiveFloat = 1200`; `SCAN_TMP_DIR` в `Settings` нет (у эмулятора — есть). +- Учётные данные передаются в каждый вызов `smbclient` (как сейчас). + +## Review Focus + +- Повторный `put` того же имени с другим содержимым → файл перезаписан новым содержимым (оба режима). +- Ошибка посреди `write` при `cleanup=False` → обрезанный файл остаётся, исключение исходное. +- Ошибка посреди `write` при `cleanup=True` и падающем `remove` → поднимается исходная ошибка записи. +- Старый `.env` с `SCAN_TMP_DIR=Tmp` → шлюз стартует (`extra="ignore"`). +- `put` не создаёт `Tmp` ни в одном режиме; эмулятор по-прежнему собирает ответ в `Tmp`. + +--- + +### Task 1: `SmbHotFolder` — прямая запись + +**Files:** +- Modify: `api/src/gateway/smb.py` (докстринг модуля, `__init__`, `put`, `_drop_part` → `_drop_partial`, `cleanup`) +- Test: `api/tests/unit/test_smb_hotfolder.py` (`TMP`, `TestPaths`, `TestPut`, `TestPutWithoutReplace` → `TestPutOverwrite`, `TestCleanup*`) + +**Interfaces:** +- Produces: `SmbHotFolder(host, path, input_dir="Input", output_dir="Output", *, login="", password="", settle_seconds=2, clock=..., cleanup=False)` — **без** `tmp_dir` и атрибута `tmp_dir`. + +- [ ] **Step 1: Переписать тесты `put` и `cleanup`** + +Удалить константу `TMP`, из `TestPaths.test_default_layout` — `assert hf.tmp_dir == TMP`, из `test_custom_dir_names` — +`tmp_dir=...` и его assert. `TestPut` и `TestPutWithoutReplace` заменить на: + +```python +class TestPut: + def test_writes_directly_into_input(self, fake: FakeSmbClient, share_root: Path) -> None: + name = inbox_name(_job_id(), "aep_5.PDF") + data = bytes(range(256)) * 10 + _hotfolder().put(name, data) + assert fake.calls == [("mkdir", (INPUT,), {}), ("open_file", (f"{INPUT}\\{name}", "wb"), {})] + assert (share_root / "Input" / name).read_bytes() == data + assert sorted(p.name for p in share_root.iterdir()) == ["Input"] # Tmp не создаётся + + def test_existing_input_dir_is_fine(self, fake: FakeSmbClient, share_root: Path) -> None: + (share_root / "Input").mkdir() + _hotfolder().put("a.pdf", b"1") + assert (share_root / "Input" / "a.pdf").read_bytes() == b"1" + + def test_missing_root_raises_and_writes_nothing(self, fake: FakeSmbClient) -> None: + with pytest.raises(OSError) as excinfo: + _hotfolder().put("a.pdf", b"1") + assert isinstance(excinfo.value, SMBOSError) + assert fake.funcs() == ["mkdir"] + assert not fake.base.exists() + + def test_mkdir_denied_but_input_exists_is_fine(self, fake: FakeSmbClient, share_root: Path) -> None: + """Урезанные ACL: нельзя создавать подкаталоги в корне, но `Input` уже есть и в него писать можно.""" + (share_root / "Input").mkdir() + fake.fail["mkdir"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT) + _hotfolder().put("a.pdf", b"1") + assert (share_root / "Input" / "a.pdf").read_bytes() == b"1" + assert fake.funcs() == ["mkdir", "isdir", "open_file"] + + def test_mkdir_denied_and_no_input_raises_original(self, fake: FakeSmbClient, share_root: Path) -> None: + denied = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT) + fake.fail["mkdir"] = denied + with pytest.raises(SMBOSError) as excinfo: + _hotfolder().put("a.pdf", b"1") + assert excinfo.value is denied + assert fake.funcs() == ["mkdir", "isdir"] + + def test_mkdir_denied_and_isdir_fails_raises_original(self, fake: FakeSmbClient, share_root: Path) -> None: + denied = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT) + fake.fail["mkdir"] = denied + fake.fail["isdir"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT) + with pytest.raises(SMBOSError) as excinfo: + _hotfolder().put("a.pdf", b"1") + assert excinfo.value is denied + assert fake.funcs() == ["mkdir", "isdir"] + + def test_empty_data_is_written(self, fake: FakeSmbClient, share_root: Path) -> None: + _hotfolder().put("empty.pdf", b"") + assert (share_root / "Input" / "empty.pdf").read_bytes() == b"" + + def test_spaces_and_cyrillic_are_accepted(self, fake: FakeSmbClient, share_root: Path) -> None: + name = inbox_name(_job_id(), "Отчёт за 2024 год (копия).pdf") + _hotfolder().put(name, b"x") + assert (share_root / "Input" / name).read_bytes() == b"x" + + # test_rejects_invalid_names_before_any_call — без изменений + + def test_write_failure_keeps_partial_file_when_cleanup_disabled( + self, fake: FakeSmbClient, share_root: Path + ) -> None: + fake.fail["open_file"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT) + with pytest.raises(SMBOSError): + _hotfolder().put("a.pdf", b"data") + assert "remove" not in fake.funcs() + + def test_write_failure_removes_partial_file_when_cleanup_enabled( + self, fake: FakeSmbClient, share_root: Path + ) -> None: + fake.fail["open_file"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT) + with pytest.raises(SMBOSError): + _hotfolder(cleanup=True).put("a.pdf", b"data") + assert fake.calls[-1] == ("remove", (f"{INPUT}\\a.pdf",), {}) + + def test_cleanup_error_does_not_mask_original(self, fake: FakeSmbClient, share_root: Path) -> None: + original = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT) + fake.fail["open_file"] = original + fake.fail["remove"] = SMBException("соединение потеряно") + with pytest.raises(SMBOSError) as excinfo: + _hotfolder(cleanup=True).put("a.pdf", b"data") + assert excinfo.value is original + assert fake.funcs() == ["mkdir", "open_file", "remove"] + + def test_passes_credentials_to_every_call(self, fake: FakeSmbClient, share_root: Path) -> None: + _hotfolder(login="user", password="secret").put("a.pdf", b"1") + assert [kwargs for _, _, kwargs in fake.calls] == [{"username": "user", "password": "secret"}] * 2 + + +class TestPutOverwrite: + def test_existing_file_is_overwritten(self, fake: FakeSmbClient, share_root: Path) -> None: + """Как в старом АРМ (решение владельца 09.10.2026): повтор задания перезаписывает входящий файл.""" + hf = _hotfolder() + hf.put("job.pdf", b"first, longer") + hf.put("job.pdf", b"second") + assert fake._local(INPUT + "\\job.pdf").read_bytes() == b"second" + assert "rename" not in fake.funcs() and "replace" not in fake.funcs() +``` + +В `TestCleanupDetails.test_orphan_part_and_done_marker_are_removed` → переименовать в +`test_done_marker_is_removed_and_tmp_is_not_touched`: `.part` в `Tmp` **остаётся**, +`removed == [f"{INPUT}\\{name}", f"{INPUT}\\{name}.done"]`, assert `(root / "Tmp" / f".{name}.part").exists()`. +`test_passes_credentials_to_every_call` (cleanup): `["scandir", "remove", "remove", "rmdir", "remove", "remove"]`. +`test_missing_answer_dir_and_files_are_fine`: `["scandir", "remove", "remove"]`. + +- [ ] **Step 2: Прогнать — ожидаемо падают** + +Run: `cd api && .venv/bin/pytest -q tests/unit/test_smb_hotfolder.py` +Expected: FAIL (`TestPut`, `TestPutOverwrite`, `TestCleanupDetails`). + +- [ ] **Step 3: Реализация `smb.py`** + +`__init__`: убрать параметр `tmp_dir` и `self.tmp_dir`. `put`: + +```python + def put(self, name: str, data: bytes) -> None: + """Записать `\\` сразу, открытием `wb` (как в старом АРМ, решение владельца 09.10.2026): временного + файла и rename нет, существующий файл перезаписывается. `smbclient.open_file` открывает файл монопольно — пока + запись идёт, ИС его не прочитает (ASSUMPTION(OQ-79)). Имя проверяется до любого обращения к шаре. При ошибке + записи недописанный файл снимается только при включённой очистке (`SCAN_CLEANUP`, R-3); ошибка снятия исходную + не маскирует. Нет корня — `SMBOSError` (ENOENT) от `mkdir`. + """ + validate_name(name) + self._ensure_dir(self.input_dir) + target = ntpath.join(self.input_dir, name) + try: + with smbclient.open_file(target, mode="wb", **self._credentials) as fd: + fd.write(data) + except BaseException: + self._drop_partial(target) + raise +``` + +`_drop_part` переименовать в `_drop_partial` (тело то же, докстринг «недописанный входящий файл»). В `cleanup` из +кортежа путей убрать `ntpath.join(self.tmp_dir, f".{name}.part")`; докстринг — «ответ из `output`, входящий файл и +`.done`». Импорт `NtStatus` и `_has_errno(..., EEXIST ...)` в `put` больше не нужны — удалить, если не используются +(`_NOT_READY` использует `NtStatus` — оставить). Докстринг модуля: «файл пишется сразу в `\\\\\<имя>` +(`wb`, перезапись, как в старом АРМ)»; убрать упоминания `tmp_dir`/`.part`/OQ-79-rename. Комментарий `noqa: PLR0913` +у `__init__` оставить, если ruff его требует (параметров по-прежнему > 5). + +- [ ] **Step 4: Прогнать** + +Run: `cd api && .venv/bin/pytest -q tests/unit/test_smb_hotfolder.py` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git -C /Users/andrey.epifancev/projects/awp-ca/.wt/smb-direct-write/scan-gateway add api/src/gateway/smb.py api/tests/unit/test_smb_hotfolder.py +git -C /Users/andrey.epifancev/projects/awp-ca/.wt/smb-direct-write/scan-gateway commit -m "feat(smb): запись прямо в Input с перезаписью, как в старом АРМ (#26)" +``` + +### Task 2: `LocalHotFolder`, фабрика, настройки + +**Files:** +- Modify: `api/src/gateway/hotfolder.py` (докстринг модуля, `LocalHotFolder` докстринг, `put`, удалить `_open_part`/`_replace_part`, `_drop_part` → `_drop_partial`, `cleanup`, `_NO_HARD_LINKS`) +- Modify: `api/src/gateway/__init__.py` (не передавать `SCAN_TMP_DIR`), `api/src/core/config.py` (`SCAN_TMP_DIR` удалить, `SCAN_TIMEOUT_SECONDS = 1200`, комментарий `SCAN_CLEANUP`) +- Test: `api/tests/unit/test_local_hotfolder.py`, `api/tests/unit/test_hotfolder_factory.py`, `api/tests/unit/test_config.py`, `api/tests/integration/test_roundtrip.py` + +**Interfaces:** +- Consumes: `SmbHotFolder` без `tmp_dir` (Task 1). +- Produces: `LocalHotFolder(root, input_dir="Input", output_dir="Output", tmp_dir="Tmp", *, ...)` — сигнатура та же, + `tmp_dir` используется только эмулятором; `Settings` без `SCAN_TMP_DIR`. + +- [ ] **Step 1: Тесты** + +`test_local_hotfolder.py`: удалить `TestPutWithoutReplace`, `TestPartIsCreatedFresh` и из `TestPut` — +`test_leaves_no_part_files`, `test_part_is_written_in_tmp_not_in_input`, `test_existing_tmp_dir_is_fine`, +`test_custom_tmp_dir_name`, `test_cleans_part_file_on_failure`, `test_keeps_part_file_on_failure_when_cleanup_disabled`, +`test_successful_put_despite_final_unlink_failure`, `test_cleanup_failure_does_not_mask_original_error`; +`test_creates_input_and_tmp_dirs_only` → `test_creates_input_dir_only` (`sorted(p.name for p in root.iterdir()) == ["Input"]`). +Добавить в `TestPut`: + +```python + def test_existing_file_is_overwritten(self, tmp_path: Path) -> None: + hf = LocalHotFolder(tmp_path, settle_seconds=0) + 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_write_failure_keeps_partial_file_when_cleanup_disabled( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + hf = LocalHotFolder(tmp_path, settle_seconds=0) + 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") +``` + +с модульными помощниками: + +```python +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, "нет прав") +``` + +`TestCleanup`: ожидания про `Tmp/..part` — `.part` **не удаляется** (файл в `Tmp` остаётся, если тест его создаёт). +`test_hotfolder_factory.py`: в `_local_settings` убрать `SCAN_TMP_DIR`; assert'ы `hf.tmp_dir == tmp_path / "Служебный"` +и `hf.tmp_dir == "...\\T"` удалить, для local — `assert hf.tmp_dir == tmp_path / "Tmp"` (умолчание эмулятора). +`test_config.py`: строки `assert s.SCAN_TMP_DIR == "Tmp"` → `assert not hasattr(s, "SCAN_TMP_DIR")`; добавить +`assert s.SCAN_TIMEOUT_SECONDS == 1200` в тест умолчаний (строка 32) и тест: + +```python +def test_legacy_scan_tmp_dir_is_ignored(monkeypatch): + monkeypatch.setenv("SCAN_TMP_DIR", "Tmp") + s = Settings(_env_file=None) + assert not hasattr(s, "SCAN_TMP_DIR") +``` + +(обязательные переменные — как в соседних тестах файла; взять их фикстуру/хелпер). `test_roundtrip.py`: убрать +`stray_parts`, `tmp_dir` у объекта стенда, `tmp_dir=settings.SCAN_TMP_DIR` (эмулятору передать `"Tmp"`), докстринги +про `.part`; в сценарии очистки — `Input/` и `Output//` пусты. + +- [ ] **Step 2: Прогнать — ожидаемо падают** + +Run: `cd api && .venv/bin/pytest -q tests/unit/test_local_hotfolder.py tests/unit/test_hotfolder_factory.py tests/unit/test_config.py` +Expected: FAIL. + +- [ ] **Step 3: Реализация** + +`LocalHotFolder.put`: + +```python + def put(self, name: str, data: bytes) -> None: + """Записать `input_dir/` сразу (`wb`, перезапись существующего, как в старом АРМ — решение владельца + 09.10.2026). `Input` создаётся без родителей, корень — нет (нет корня — `FileNotFoundError`). При ошибке записи + недописанный файл снимается только при включённой очистке (R-3), ошибка снятия подавляется.""" + validate_name(name) + self.input_dir.mkdir(exist_ok=True) + target = self.input_dir / name + try: + target.write_bytes(data) + except BaseException: + self._drop_partial(target) + raise + + def _drop_partial(self, path: Path) -> None: + if self.cleanup_enabled: + with contextlib.suppress(OSError): + path.unlink(missing_ok=True) +``` + +Удалить `_open_part`, `_replace_part`, `_NO_HARD_LINKS` и неиспользуемые импорты (`errno`, `os` — проверить ruff). +`cleanup`: кортеж путей без `self.tmp_dir / f".{name}.part"`. Докстринги модуля и класса: прямая запись в `Input`; +`tmp_dir` — служебный каталог эмулятора (сборка набора ответа), `put` его не использует. +`gateway/__init__.py`: в `LocalHotFolder(...)` не передавать `settings.SCAN_TMP_DIR` (умолчание `"Tmp"`), в +`SmbHotFolder(...)` убрать аргумент. `config.py`: удалить `SCAN_TMP_DIR`; `SCAN_TIMEOUT_SECONDS: PositiveFloat = 1200` +(комментарий: «20 мин, в паре со сроком ответа OCR ядра»); комментарий `SCAN_CLEANUP` — без `.part в Tmp`. + +- [ ] **Step 4: Прогнать unit** + +Run: `cd api && .venv/bin/pytest -q tests/unit` +Expected: PASS (включая `test_emulator.py` — эмулятор пользуется `tmp_dir`). + +- [ ] **Step 5: Commit** + +```bash +git -C /Users/andrey.epifancev/projects/awp-ca/.wt/smb-direct-write/scan-gateway add -A api +git -C /Users/andrey.epifancev/projects/awp-ca/.wt/smb-direct-write/scan-gateway commit -m "feat(hotfolder): local тоже пишет прямо в Input; без SCAN_TMP_DIR; таймаут 1200 с (#26)" +``` + +### Task 3: Документация и полная проверка + +**Files:** +- Modify: `README.md` (схема конвейера п. 5, раздел протокола горячих папок, «Очистка», таблица env: убрать `SCAN_TMP_DIR` шлюза, `SCAN_TIMEOUT_SECONDS` = `1200`, права на SMB), `CLAUDE.md` («Что это», «Горячие папки»), `api/.env.example` (`SCAN_TMP_DIR` — в блок эмулятора, `SCAN_TIMEOUT_SECONDS`), `openspec/changes/hotfolder-direct-write/tasks.md` (отметки) + +- [ ] **Step 1: Правки текста** + +`git grep -n "Tmp\|\.part\|SCAN_TMP_DIR\|SCAN_TIMEOUT_SECONDS\|300" -- README.md CLAUDE.md api/.env.example` и привести +каждое место к: «файл пишется сразу в `Input/.` (`wb`, существующий перезаписывается, как в старом АРМ; +ИС не берёт файл, пока он открыт на запись — ASSUMPTION(OQ-79))»; `Tmp` и `SCAN_TMP_DIR` упоминаются только у +эмулятора; права учётки шары — запись в `Input`, чтение `Output`, удаление при `SCAN_CLEANUP`; таймаут 1200 с. + +- [ ] **Step 2: Полная проверка** + +Run: `make check-full` (из корня репозитория; нужен поднятый стенд `make up`) +Expected: exit 0. Вывод — в описание PR. + +- [ ] **Step 3: Commit** + +```bash +git -C /Users/andrey.epifancev/projects/awp-ca/.wt/smb-direct-write/scan-gateway add -A README.md CLAUDE.md api/.env.example openspec +git -C /Users/andrey.epifancev/projects/awp-ca/.wt/smb-direct-write/scan-gateway commit -m "docs: прямая запись в Input, SCAN_TMP_DIR только у эмулятора, таймаут 1200 с (#26)" +``` -- 2.47.2 From 6870ca799a23b4061e5aa55306013ed624ef3861 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 9 Oct 2026 16:30:31 +0400 Subject: [PATCH 3/6] =?UTF-8?q?feat(smb):=20=D0=B7=D0=B0=D0=BF=D0=B8=D1=81?= =?UTF-8?q?=D1=8C=20=D0=BF=D1=80=D1=8F=D0=BC=D0=BE=20=D0=B2=20Input=20?= =?UTF-8?q?=D1=81=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B7=D0=B0=D0=BF=D0=B8=D1=81?= =?UTF-8?q?=D1=8C=D1=8E,=20=D0=BA=D0=B0=D0=BA=20=D0=B2=20=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D1=80=D0=BE=D0=BC=20=D0=90=D0=A0=D0=9C=20(#26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- api/src/gateway/__init__.py | 1 - api/src/gateway/smb.py | 48 +++---- api/tests/unit/test_hotfolder_factory.py | 3 +- api/tests/unit/test_smb_hotfolder.py | 165 ++++++----------------- 4 files changed, 62 insertions(+), 155 deletions(-) diff --git a/api/src/gateway/__init__.py b/api/src/gateway/__init__.py index f6e785b..5d5265e 100644 --- a/api/src/gateway/__init__.py +++ b/api/src/gateway/__init__.py @@ -31,7 +31,6 @@ def create_hotfolder(settings: Settings) -> HotFolder: smb.SMB_PATH, settings.SCAN_INPUT_DIR, settings.SCAN_OUTPUT_DIR, - settings.SCAN_TMP_DIR, login=smb.SMB_LOGIN, password=smb.SMB_PASSWORD, settle_seconds=settings.SCAN_SETTLE_SECONDS, diff --git a/api/src/gateway/smb.py b/api/src/gateway/smb.py index 91bcb76..5f2c62f 100644 --- a/api/src/gateway/smb.py +++ b/api/src/gateway/smb.py @@ -4,10 +4,10 @@ r"""Горячая папка на SMB-шаре платформы (`SCAN_HOTFOL в lifespan приложения (`app.py`); логин/пароль, если заданы, передаются ещё и в каждый вызов `smbclient`, чтобы сессия поднималась заново по требованию (после сбоя регистрации при старте или сброса соединения). -Пути — UNC: временный файл `\\\\\.<имя>.part` переименовывается (rename без замены) -во входящий `\\\\\<имя>` (одна шара — rename атомарен, OQ-79); ответ — все `*.json` -каталога `\\\\\\` (OQ-78), готовность — по стабилизации набора -(`SettleTracker`). Очистка (D-2) удаляет ответы, входящий файл и временные файлы при `SCAN_CLEANUP=true`. +Пути — UNC: входящий файл пишется сразу в `\\\\\<имя>` (`wb`, существующий перезаписывается, +как в старом АРМ — решение владельца 09.10.2026, change `hotfolder-direct-write`); ответ — все `*.json` каталога +`\\\\\\` (OQ-78), готовность — по стабилизации набора (`SettleTracker`). +Очистка (D-2) удаляет ответы, входящий файл и его `.done` при `SCAN_CLEANUP=true`. Ошибки smbprotocol — `SMBOSError` (подкласс `OSError`, но НЕ `FileNotFoundError`): «нет файла/каталога» распознаётся по `errno == ENOENT`. """ @@ -66,9 +66,9 @@ def build_unc_root(host: str, path: str) -> str: class SmbHotFolder: """Горячая папка ИС Сканирование на SMB-шаре. Методы синхронные (блокирующий SMB), семантика как у - `LocalHotFolder`: те же правила имён, атомарная запись `Tmp\\..part` + rename без замены в `Input`, - `Tmp` и `Input` создаются без родителей, корень не создаётся, готовность набора ответа — по стабилизации - (`SettleTracker`). Очистка (D-2) под флагом `SCAN_CLEANUP`.""" + `LocalHotFolder`: те же правила имён, запись сразу в `Input` с перезаписью, `Input` создаётся без родителей, корень + не создаётся, готовность набора ответа — по стабилизации (`SettleTracker`). Очистка (D-2) под флагом + `SCAN_CLEANUP`.""" def __init__( # noqa: PLR0913 — адрес и учётные данные D-1 + имена каталогов и готовность, как у LocalHotFolder self, @@ -76,7 +76,6 @@ class SmbHotFolder: path: str, input_dir: str = "Input", output_dir: str = "Output", - tmp_dir: str = "Tmp", *, login: str = "", password: str = "", @@ -87,7 +86,6 @@ class SmbHotFolder: self.root: str = build_unc_root(host, path) self.input_dir: str = ntpath.join(self.root, input_dir) self.output_dir: str = ntpath.join(self.root, output_dir) - self.tmp_dir: str = ntpath.join(self.root, tmp_dir) self._credentials: dict[str, Any] = {"username": login, "password": password} if login else {} self._settle = SettleTracker(settle_seconds, clock) self.cleanup_enabled = cleanup @@ -102,29 +100,20 @@ class SmbHotFolder: return ntpath.join(self.answer_dir(job_id), f"{job_id}.json") def put(self, name: str, data: bytes) -> None: - """Записать `..part` в служебный каталог, затем `smbclient.rename` в `\\` без замены. - Имя проверяется до любого обращения к шаре. Назначение уже есть (повтор задания) — это не ошибка: файл - считается доставленным и не заменяется. В этом случае, как и при любой другой ошибке записи или переименования, - `.part` снимается только при включённой очистке (`SCAN_CLEANUP`, D-2), иначе остаётся в `Tmp`; ошибка снятия - исходную не маскирует. При успехе `.part` забирает сам rename. Нет корня — `SMBOSError` (ENOENT) от первого - `mkdir`. + """Записать `\\` сразу, открытием `wb` (как в старом АРМ, решение владельца 09.10.2026): временного + файла и rename нет, существующий файл перезаписывается. `smbclient.open_file` открывает файл монопольно — пока + запись идёт, ИС его не прочитает (ASSUMPTION(OQ-79)). Имя проверяется до любого обращения к шаре. При ошибке + записи недописанный файл снимается только при включённой очистке (`SCAN_CLEANUP`, R-3); ошибка снятия исходную + не маскирует. Нет корня — `SMBOSError` (ENOENT) от `mkdir`. """ validate_name(name) - self._ensure_dir(self.tmp_dir) self._ensure_dir(self.input_dir) - part = ntpath.join(self.tmp_dir, f".{name}.part") + target = ntpath.join(self.input_dir, name) try: - with smbclient.open_file(part, mode="wb", **self._credentials) as fd: + with smbclient.open_file(target, mode="wb", **self._credentials) as fd: fd.write(data) - smbclient.rename(part, ntpath.join(self.input_dir, name), **self._credentials) - except OSError as err: - self._drop_part(part) - ntstatus = getattr(err, "ntstatus", None) - if _has_errno(err, errno.EEXIST, FileExistsError) or ntstatus == NtStatus.STATUS_OBJECT_NAME_COLLISION: - return - raise except BaseException: - self._drop_part(part) + self._drop_partial(target) raise def poll(self, job_id: str) -> list[AnswerFile] | None: @@ -166,14 +155,14 @@ class SmbHotFolder: """Число заданий с ненастоявшимся набором (для тестов и диагностики).""" return len(self._settle) - def _drop_part(self, path: str) -> None: - """Удалить временный файл при ошибке, только если очистка включена (D-2).""" + def _drop_partial(self, path: str) -> None: + """Удалить недописанный входящий файл при ошибке записи, только если очистка включена (D-2, R-3).""" if self.cleanup_enabled: with contextlib.suppress(Exception): smbclient.remove(path, **self._credentials) def cleanup(self, job_id: str, name: str) -> bool: - """Удалить следы задания: ответ из `output`, входящий файл, временные файлы. Возвращает `False`, если + """Удалить следы задания: ответ из `output`, входящий файл и его `.done`. Возвращает `False`, если очистка выключена; `True` при успешной очистке. Ошибки удаления (кроме ENOENT) поднимаются.""" if not self.cleanup_enabled: return False @@ -194,7 +183,6 @@ class SmbHotFolder: for path in ( ntpath.join(self.input_dir, name), ntpath.join(self.input_dir, f"{name}.done"), - ntpath.join(self.tmp_dir, f".{name}.part"), ): try: smbclient.remove(path, **self._credentials) diff --git a/api/tests/unit/test_hotfolder_factory.py b/api/tests/unit/test_hotfolder_factory.py index 44262d2..b0bc971 100644 --- a/api/tests/unit/test_hotfolder_factory.py +++ b/api/tests/unit/test_hotfolder_factory.py @@ -33,7 +33,6 @@ def _smb_settings() -> Settings: update={ "SCAN_HOTFOLDER_MODE": "smb", "SCAN_OUTPUT_DIR": "Out", - "SCAN_TMP_DIR": "T", "SCAN_SETTLE_SECONDS": 3, "OPENBAO": settings.OPENBAO.model_copy(update={"SMB": smb}), } @@ -66,7 +65,7 @@ def test_smb_mode_builds_smb_hotfolder() -> None: assert hf.root == "\\\\smb.example.local\\share\\scan" assert hf.input_dir == "\\\\smb.example.local\\share\\scan\\Input" assert hf.output_dir == "\\\\smb.example.local\\share\\scan\\Out" - assert hf.tmp_dir == "\\\\smb.example.local\\share\\scan\\T" + assert not hasattr(hf, "tmp_dir") # у шлюза служебного каталога нет (change hotfolder-direct-write) assert hf.settle_seconds == 3 diff --git a/api/tests/unit/test_smb_hotfolder.py b/api/tests/unit/test_smb_hotfolder.py index 74df057..5962743 100644 --- a/api/tests/unit/test_smb_hotfolder.py +++ b/api/tests/unit/test_smb_hotfolder.py @@ -21,7 +21,6 @@ HOST = "smb.example.local" ROOT = "\\\\smb.example.local\\share\\scan" INPUT = ROOT + "\\Input" OUTPUT = ROOT + "\\Output" -TMP = ROOT + "\\Tmp" class FakeClock: @@ -178,7 +177,6 @@ class TestPaths: assert hf.root == ROOT assert hf.input_dir == INPUT assert hf.output_dir == OUTPUT - assert hf.tmp_dir == TMP assert hf.answer_dir(job_id) == f"{OUTPUT}\\{job_id}" assert hf.answer_path(job_id) == f"{OUTPUT}\\{job_id}\\{job_id}.json" @@ -194,10 +192,9 @@ class TestPaths: assert SmbHotFolder(HOST, "share").root == "\\\\smb.example.local\\share" def test_custom_dir_names(self) -> None: - hf = SmbHotFolder(HOST, "share/scan", input_dir="Входящие", output_dir="Исходящие", tmp_dir="Служебный") + hf = SmbHotFolder(HOST, "share/scan", input_dir="Входящие", output_dir="Исходящие") assert hf.input_dir == ROOT + "\\Входящие" assert hf.output_dir == ROOT + "\\Исходящие" - assert hf.tmp_dir == ROOT + "\\Служебный" assert hf.answer_path("abc") == ROOT + "\\Исходящие\\abc\\abc.json" @pytest.mark.parametrize(("host", "path"), [(HOST, ""), (HOST, "/"), ("", "share/scan"), ("", "\\\\srv")]) @@ -226,63 +223,13 @@ class TestPaths: class TestPut: - def test_writes_part_in_tmp_then_renames_into_input(self, fake: FakeSmbClient, share_root: Path) -> None: + def test_writes_directly_into_input(self, fake: FakeSmbClient, share_root: Path) -> None: name = inbox_name(_job_id(), "aep_5.PDF") data = bytes(range(256)) * 10 _hotfolder().put(name, data) - part = f"{TMP}\\.{name}.part" - assert fake.calls == [ - ("mkdir", (TMP,), {}), - ("mkdir", (INPUT,), {}), - ("open_file", (part, "wb"), {}), - ("rename", (part, f"{INPUT}\\{name}"), {}), - ] + assert fake.calls == [("mkdir", (INPUT,), {}), ("open_file", (f"{INPUT}\\{name}", "wb"), {})] assert (share_root / "Input" / name).read_bytes() == data - assert [p.name for p in (share_root / "Input").iterdir()] == [name] - assert list((share_root / "Tmp").iterdir()) == [] - - def test_input_has_no_part_during_write(self, fake: FakeSmbClient, share_root: Path) -> None: - """OQ-79: пока пишется временный файл, во входящем каталоге его нет — он в `Tmp`.""" - name = "a.pdf" - seen: dict[str, list[str]] = {} - real_rename = fake.rename - - def spy(src: str, dst: str, **kwargs: Any) -> None: - seen["input"] = sorted(p.name for p in (share_root / "Input").iterdir()) - seen["tmp"] = sorted(p.name for p in (share_root / "Tmp").iterdir()) - real_rename(src, dst, **kwargs) - - fake.rename = spy # type: ignore[method-assign] - _hotfolder().put(name, b"data") - assert seen == {"input": [], "tmp": [".a.pdf.part"]} - assert [p.name for p in (share_root / "Input").iterdir()] == [name] - - def test_existing_tmp_dir_is_fine(self, fake: FakeSmbClient, share_root: Path) -> None: - (share_root / "Tmp").mkdir() - _hotfolder().put("a.pdf", b"1") - assert (share_root / "Input" / "a.pdf").read_bytes() == b"1" - - def test_tmp_mkdir_denied_but_tmp_exists_is_fine(self, fake: FakeSmbClient, share_root: Path) -> None: - (share_root / "Tmp").mkdir() - (share_root / "Input").mkdir() - fake.fail["mkdir"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, TMP) - _hotfolder().put("a.pdf", b"1") - assert (share_root / "Input" / "a.pdf").read_bytes() == b"1" - assert fake.calls[:4] == [ - ("mkdir", (TMP,), {}), - ("isdir", (TMP,), {}), - ("mkdir", (INPUT,), {}), - ("isdir", (INPUT,), {}), - ] - - def test_tmp_mkdir_denied_and_no_tmp_raises_original(self, fake: FakeSmbClient, share_root: Path) -> None: - (share_root / "Input").mkdir() - denied = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, TMP) - fake.fail["mkdir"] = denied - with pytest.raises(SMBOSError) as excinfo: - _hotfolder().put("a.pdf", b"1") - assert excinfo.value is denied - assert fake.funcs() == ["mkdir", "isdir"] + assert sorted(p.name for p in share_root.iterdir()) == ["Input"] # Tmp не создаётся def test_existing_input_dir_is_fine(self, fake: FakeSmbClient, share_root: Path) -> None: (share_root / "Input").mkdir() @@ -298,41 +245,29 @@ class TestPut: def test_mkdir_denied_but_input_exists_is_fine(self, fake: FakeSmbClient, share_root: Path) -> None: """Урезанные ACL: нельзя создавать подкаталоги в корне, но `Input` уже есть и в него писать можно.""" - (share_root / "Tmp").mkdir() (share_root / "Input").mkdir() fake.fail["mkdir"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT) _hotfolder().put("a.pdf", b"1") assert (share_root / "Input" / "a.pdf").read_bytes() == b"1" - assert fake.funcs() == ["mkdir", "isdir", "mkdir", "isdir", "open_file", "rename"] - assert fake.calls[3] == ("isdir", (INPUT,), {}) + assert fake.funcs() == ["mkdir", "isdir", "open_file"] def test_mkdir_denied_and_no_input_raises_original(self, fake: FakeSmbClient, share_root: Path) -> None: - (share_root / "Tmp").mkdir() denied = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT) fake.fail["mkdir"] = denied with pytest.raises(SMBOSError) as excinfo: _hotfolder().put("a.pdf", b"1") assert excinfo.value is denied - assert fake.funcs() == ["mkdir", "isdir", "mkdir", "isdir"] + assert fake.funcs() == ["mkdir", "isdir"] def test_mkdir_denied_and_isdir_fails_raises_original(self, fake: FakeSmbClient, share_root: Path) -> None: - denied = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, TMP) + denied = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT) fake.fail["mkdir"] = denied - fake.fail["isdir"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, TMP) + fake.fail["isdir"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT) with pytest.raises(SMBOSError) as excinfo: _hotfolder().put("a.pdf", b"1") assert excinfo.value is denied assert fake.funcs() == ["mkdir", "isdir"] - @pytest.mark.parametrize("plain", [False, True], ids=["SMBOSError", "FileExistsError"]) - def test_no_replace_on_existing_file(self, fake: FakeSmbClient, share_root: Path, plain: bool) -> None: - fake.plain_collision = plain - hf = _hotfolder() - hf.put("a.pdf", b"old") - hf.put("a.pdf", b"new") - assert (share_root / "Input" / "a.pdf").read_bytes() == b"old" - assert (share_root / "Tmp" / ".a.pdf.part").read_bytes() == b"new" - def test_empty_data_is_written(self, fake: FakeSmbClient, share_root: Path) -> None: _hotfolder().put("empty.pdf", b"") assert (share_root / "Input" / "empty.pdf").read_bytes() == b"" @@ -371,58 +306,44 @@ class TestPut: _hotfolder().put(name, b"x") assert fake.calls == [] - def test_cleans_part_file_on_rename_failure(self, fake: FakeSmbClient, share_root: Path) -> None: - fake.fail["rename"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT) - with pytest.raises(OSError): - _hotfolder(cleanup=True).put("a.pdf", b"data") - part = f"{TMP}\\.a.pdf.part" - assert fake.calls[-1] == ("remove", (part,), {}) - assert list((share_root / "Input").iterdir()) == [] - assert list((share_root / "Tmp").iterdir()) == [] + def test_write_failure_keeps_partial_file_when_cleanup_disabled( + self, fake: FakeSmbClient, share_root: Path + ) -> None: + fake.fail["open_file"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT) + with pytest.raises(SMBOSError): + _hotfolder().put("a.pdf", b"data") + assert "remove" not in fake.funcs() - def test_cleanup_error_does_not_mask_original(self, fake: FakeSmbClient, share_root: Path) -> None: - fake.fail["rename"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT) - fake.fail["remove"] = SMBException("соединение потеряно") + def test_write_failure_removes_partial_file_when_cleanup_enabled( + self, fake: FakeSmbClient, share_root: Path + ) -> None: + fake.fail["open_file"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT) with pytest.raises(SMBOSError): _hotfolder(cleanup=True).put("a.pdf", b"data") - assert fake.funcs() == ["mkdir", "mkdir", "open_file", "rename", "remove"] + assert fake.calls[-1] == ("remove", (f"{INPUT}\\a.pdf",), {}) + + def test_cleanup_error_does_not_mask_original(self, fake: FakeSmbClient, share_root: Path) -> None: + original = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT) + fake.fail["open_file"] = original + fake.fail["remove"] = SMBException("соединение потеряно") + with pytest.raises(SMBOSError) as excinfo: + _hotfolder(cleanup=True).put("a.pdf", b"data") + assert excinfo.value is original + assert fake.funcs() == ["mkdir", "open_file", "remove"] def test_passes_credentials_to_every_call(self, fake: FakeSmbClient, share_root: Path) -> None: _hotfolder(login="user", password="secret").put("a.pdf", b"1") - assert [kwargs for _, _, kwargs in fake.calls] == [{"username": "user", "password": "secret"}] * 4 + assert [kwargs for _, _, kwargs in fake.calls] == [{"username": "user", "password": "secret"}] * 2 -class TestPutWithoutReplace: - def test_uses_rename_not_replace(self, fake: FakeSmbClient, share_root: Path) -> None: +class TestPutOverwrite: + def test_existing_file_is_overwritten(self, fake: FakeSmbClient, share_root: Path) -> None: + """Как в старом АРМ (решение владельца 09.10.2026): повтор задания перезаписывает входящий файл.""" hf = _hotfolder() - hf.put("job.pdf", b"x") - names = [call[0] for call in fake.calls] - assert "rename" in names and "replace" not in names - assert fake._local(INPUT + "\\job.pdf").read_bytes() == b"x" - assert not fake._local(TMP + "\\.job.pdf.part").exists() - - @pytest.mark.parametrize("plain", [False, True], ids=["SMBOSError", "FileExistsError"]) - def test_existing_destination_kept_and_part_left(self, fake: FakeSmbClient, share_root: Path, plain: bool) -> None: - fake.plain_collision = plain - hf = _hotfolder() - hf.put("job.pdf", b"first") + hf.put("job.pdf", b"first, longer") hf.put("job.pdf", b"second") - assert fake._local(INPUT + "\\job.pdf").read_bytes() == b"first" - assert fake._local(TMP + "\\.job.pdf.part").read_bytes() == b"second" - - @pytest.mark.parametrize("plain", [False, True], ids=["SMBOSError", "FileExistsError"]) - def test_existing_destination_drops_part_when_cleanup_enabled( - self, fake: FakeSmbClient, share_root: Path, plain: bool - ) -> None: - """Назначение есть (повтор задания) и очистка включена: файл не заменён, `.part` снят.""" - fake.plain_collision = plain - hf = _hotfolder(cleanup=True) - hf.put("job.pdf", b"first") - fake.calls.clear() - hf.put("job.pdf", b"second") # исключения нет: назначение есть — файл считается доставленным - assert fake._local(INPUT + "\\job.pdf").read_bytes() == b"first" - assert not fake._local(TMP + "\\.job.pdf.part").exists() - assert fake.calls[-1] == ("remove", (f"{TMP}\\.job.pdf.part",), {}) + assert fake._local(INPUT + "\\job.pdf").read_bytes() == b"second" + assert "rename" not in fake.funcs() and "replace" not in fake.funcs() class TestCleanup: @@ -481,7 +402,7 @@ class TestCleanupDetails: hf.cleanup(_job_id(), name) assert fake.calls == [] - def test_orphan_part_and_done_marker_are_removed(self, fake: FakeSmbClient) -> None: + def test_done_marker_is_removed_and_tmp_is_not_touched(self, fake: FakeSmbClient) -> None: hf = self._hf(fake) job_id = _job_id() name = f"{job_id}.pdf" @@ -490,14 +411,14 @@ class TestCleanupDetails: (root / folder).mkdir() (root / "Input" / name).write_bytes(b"pdf") (root / "Input" / f"{name}.done").write_bytes(b"") # маркер ИС - (root / "Tmp" / f".{name}.part").write_bytes(b"orphan") # `.part` осиротевшей записи + (root / "Tmp" / f".{name}.part").write_bytes(b"legacy") # остаток прежних версий шлюза — не наш other = root / "Input" / f"{_job_id()}.pdf" other.write_bytes(b"other job") # чужое задание не трогаем assert hf.cleanup(job_id, name) is True assert sorted(p.name for p in (root / "Input").iterdir()) == [other.name] - assert list((root / "Tmp").iterdir()) == [] + assert (root / "Tmp" / f".{name}.part").exists() removed = [args[0] for func, args, _ in fake.calls if func == "remove"] - assert removed == [f"{INPUT}\\{name}", f"{INPUT}\\{name}.done", f"{TMP}\\.{name}.part"] + assert removed == [f"{INPUT}\\{name}", f"{INPUT}\\{name}.done"] def test_scandir_access_denied_propagates_and_nothing_else_is_touched(self, fake: FakeSmbClient) -> None: hf = self._hf(fake) @@ -523,7 +444,7 @@ class TestCleanupDetails: assert excinfo.value.ntstatus == NtStatus.STATUS_DIRECTORY_NOT_EMPTY assert not (answer / f"{job_id}.json").exists() # файлы сняты, каталог остался вместе с подкаталогом assert (answer / "nested").is_dir() - assert fake.funcs()[-1] == "rmdir" # дальше (входящий файл, `.done`, `.part`) очистка не пошла + assert fake.funcs()[-1] == "rmdir" # дальше (входящий файл, `.done`) очистка не пошла def test_passes_credentials_to_every_call(self, fake: FakeSmbClient) -> None: hf = self._hf(fake, login="user", password="secret") @@ -536,13 +457,13 @@ class TestCleanupDetails: (answer / f"{job_id}_ofr.json").write_bytes(b"{}") fake.calls.clear() assert hf.cleanup(job_id, name) is True - assert fake.funcs() == ["scandir", "remove", "remove", "rmdir", "remove", "remove", "remove"] + assert fake.funcs() == ["scandir", "remove", "remove", "rmdir", "remove", "remove"] assert all(kwargs == {"username": "user", "password": "secret"} for _, _, kwargs in fake.calls) def test_missing_answer_dir_and_files_are_fine(self, fake: FakeSmbClient) -> None: hf = self._hf(fake) assert hf.cleanup(_job_id(), "nothing.pdf") is True - assert fake.funcs() == ["scandir", "remove", "remove", "remove"] + assert fake.funcs() == ["scandir", "remove", "remove"] def test_answer_dir_is_a_file_is_fine(self, fake: FakeSmbClient) -> None: hf = self._hf(fake) -- 2.47.2 From 7dc0317b99cae819541678b6e35c05050a9ffe2d Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 9 Oct 2026 16:34:39 +0400 Subject: [PATCH 4/6] =?UTF-8?q?feat(hotfolder):=20local=20=D1=82=D0=BE?= =?UTF-8?q?=D0=B6=D0=B5=20=D0=BF=D0=B8=D1=88=D0=B5=D1=82=20=D0=BF=D1=80?= =?UTF-8?q?=D1=8F=D0=BC=D0=BE=20=D0=B2=20Input;=20=D0=B1=D0=B5=D0=B7=20SCA?= =?UTF-8?q?N=5FTMP=5FDIR;=20=D1=82=D0=B0=D0=B9=D0=BC=D0=B0=D1=83=D1=82=201?= =?UTF-8?q?200=20=D1=81=20(#26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- api/src/core/config.py | 6 +- api/src/gateway/__init__.py | 1 - api/src/gateway/hotfolder.py | 86 +----- api/tests/integration/test_roundtrip.py | 21 +- api/tests/unit/conftest.py | 1 - api/tests/unit/test_config.py | 18 +- api/tests/unit/test_hotfolder_factory.py | 3 +- api/tests/unit/test_local_hotfolder.py | 344 ++++------------------- api/tests/unit/test_worker.py | 5 +- 9 files changed, 87 insertions(+), 398 deletions(-) diff --git a/api/src/core/config.py b/api/src/core/config.py index b14165f..db9eb68 100644 --- a/api/src/core/config.py +++ b/api/src/core/config.py @@ -83,13 +83,13 @@ class Settings(BaseSettings): SCAN_HOTFOLDER_PATH: str = "" # корень горячей папки в режиме local (каталог должен существовать) SCAN_INPUT_DIR: str = "Input" SCAN_OUTPUT_DIR: str = "Output" - SCAN_TMP_DIR: str = "Tmp" # служебный каталог временных файлов записи (рядом с Input, на той же ФС/шаре) SCAN_POLL_SECONDS: PositiveFloat = 5 # период опроса «Исходящих» # набор *.json ответа готов, когда имена и размеры не менялись столько секунд; 0 — готов сразу (тесты) 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 # одновременно обрабатываемых заданий - # Очистка горячей папки после прочтения набора ответа (D-2): любое удаление, включая .part в Tmp, только при true. + # Очистка горячей папки после прочтения набора ответа (D-2): ответ, входящий файл, .done — только при true. # На SMB-шаре контура права на удаление не подтверждены — по умолчанию выключено SCAN_CLEANUP: bool = False SCAN_RULES_DIR: str = "rules" # справочники нормализатора (yaml), путь от текущего каталога (api/) diff --git a/api/src/gateway/__init__.py b/api/src/gateway/__init__.py index 5d5265e..fb726df 100644 --- a/api/src/gateway/__init__.py +++ b/api/src/gateway/__init__.py @@ -18,7 +18,6 @@ def create_hotfolder(settings: Settings) -> HotFolder: settings.SCAN_HOTFOLDER_PATH, settings.SCAN_INPUT_DIR, settings.SCAN_OUTPUT_DIR, - settings.SCAN_TMP_DIR, settle_seconds=settings.SCAN_SETTLE_SECONDS, cleanup=settings.SCAN_CLEANUP, ) diff --git a/api/src/gateway/hotfolder.py b/api/src/gateway/hotfolder.py index 145d910..e1a2a01 100644 --- a/api/src/gateway/hotfolder.py +++ b/api/src/gateway/hotfolder.py @@ -1,17 +1,16 @@ """Горячие папки ИС Сканирование: протокол `HotFolder` и реализация в обычном каталоге (D-1, D-2, R-4, R-5). -Файл кладётся во входящий каталог как `.` (D-1): сначала временный `.<имя>.part` в служебном каталоге -`Tmp` (на той же ФС/шаре), затем жёсткая ссылка (без замены) во входящий (решение №17). Ответ ИС — набор файлов каталога +Файл кладётся во входящий каталог как `.` (D-1) сразу, открытием `wb` с перезаписью существующего — как в +старом АРМ (решение владельца 09.10.2026, change `hotfolder-direct-write`). Ответ ИС — набор файлов каталога `//`: ИС кладёт туда все распознанные документы задания, по одному `*.json` на документ (OQ-78). `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 errno import os import re import shutil @@ -29,8 +28,6 @@ _ILLEGAL_CHARS = frozenset(':<>"|?*') | frozenset(chr(code) for code in range(0x _FORBIDDEN_CHARS = _ILLEGAL_CHARS | frozenset(_PATH_SEPARATORS) # Расширение входящего файла — суффикс после последней точки, в нижнем регистре, 1–16 символов ASCII [0-9a-z] _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: @@ -171,7 +168,8 @@ class LocalHotFolder: """Горячая папка в обычном каталоге: dev, compose-том, unit-тесты. `root`, `input_dir`, `output_dir`, `tmp_dir`, `answer_dir` и `answer_path` открыты намеренно (R-4): эмулятор - ИС Сканирование работает с ними обычными операциями файловой системы (листинг входящих, `.done`, запись ответа). + ИС Сканирование работает с ними обычными операциями файловой системы (листинг входящих, `.done`, запись ответа); + `tmp_dir` нужен только эмулятору (сборка набора ответа), `put` его не использует. `settle_seconds` и `clock` (монотонные часы) — готовность набора ответа (`SettleTracker`). `cleanup_enabled` (D-2) — очистка включена при `SCAN_CLEANUP`. """ @@ -204,76 +202,22 @@ class LocalHotFolder: return self.answer_dir(job_id) / f"{job_id}.json" def put(self, name: str, data: bytes) -> None: - """Атомарная запись без замены: `..part` в `tmp_dir`, затем жёсткая ссылка в `input_dir/` и - удаление `.part` как часть успешной публикации (R-8). Назначение уже есть — файл считается доставленным - (повтор задания, R-17): ничего не пишется, `.part` снимается только при включённой очистке. Корень не - создаётся (нет корня — `FileNotFoundError`).""" + """Записать `input_dir/` сразу (`wb`, перезапись существующего, как в старом АРМ — решение владельца + 09.10.2026). `Input` создаётся без родителей, корень — нет (нет корня — `FileNotFoundError`). При ошибке записи + недописанный файл снимается только при включённой очистке (R-3), ошибка снятия подавляется.""" validate_name(name) - self.tmp_dir.mkdir(exist_ok=True) self.input_dir.mkdir(exist_ok=True) - part = self.tmp_dir / f".{name}.part" 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: - with os.fdopen(fd, "wb") as handle: - handle.write(data) + target.write_bytes(data) except BaseException: - self._drop_part(part) - 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) + self._drop_partial(target) raise - def _drop_part(self, part: Path) -> None: + def _drop_partial(self, path: Path) -> None: if self.cleanup_enabled: with contextlib.suppress(OSError): - part.unlink(missing_ok=True) + path.unlink(missing_ok=True) def cleanup(self, job_id: str, name: str) -> bool: if not self.cleanup_enabled: @@ -282,7 +226,7 @@ class LocalHotFolder: folder = self.answer_dir(job_id) if folder.is_dir(): 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: os.unlink(path) except FileNotFoundError: diff --git a/api/tests/integration/test_roundtrip.py b/api/tests/integration/test_roundtrip.py index b74aaef..20f9363 100644 --- a/api/tests/integration/test_roundtrip.py +++ b/api/tests/integration/test_roundtrip.py @@ -3,7 +3,7 @@ Настоящий lifespan приложения (`create_app` → S3, NATS, воркер, `OcrNormalizer` со справочниками `api/rules/`) и эмулятор ИС Сканирование (`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-каталог. -Входящий файл — `.pdf` (расширение `original_name` в нижнем регистре, `.part` живёт в `Tmp`), пару фикстур +Входящий файл — `.pdf` (расширение `original_name` в нижнем регистре, пишется прямо в `Input`), пару фикстур эмулятор находит по sha256 содержимого: в его каталоге фикстур лежат PDF и ответы (реальные `aep_*` из `fixtures/ocr` и синтетический `ok_doc`). @@ -16,7 +16,7 @@ 3. `aep_6.pdf` (один успешный файл) → `needs_check`, `result_s3` с двумя документами (`BALANCE`, `INCOME`); 4. `ok_doc.pdf` (синтетический успешный ответ в форме реальных) → `needs_check` (редакция `fsbu4` пуста) с `result_s3`; 5. `unknown.pdf` (содержимого нет в фикстурах) → ack, затем `INVALID_DOCUMENT` эмулятора; -6. `aep_6.pdf` при `SCAN_CLEANUP=true` → после результата `Input/` и `Output//` пусты, `Tmp/` без `.part`. +6. `aep_6.pdf` при `SCAN_CLEANUP=true` → после результата `Input/` и `Output//` пусты. Каждый конверт проверяется схемой `recognition-result-message`, `service.name` и `Nats-Msg-Id`; файл результата из MinIO — схемой `recognition-result`; склеенный сырой ответ в MinIO содержит ровно те файлы, что лежат в каталоге ответа, в порядке возрастания имени, с телами байт в байт; входящий файл совпадает с объектом S3. Готовность набора @@ -178,10 +178,6 @@ class Roundtrip: def input_dir(self) -> Path: 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: return self.root / self.settings.SCAN_OUTPUT_DIR / job_id @@ -260,12 +256,6 @@ class Roundtrip: 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)) - 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]: """Запрос → ack → итоговый конверт; общие проверки сквозного сценария. Возвращает запрос и итог.""" body = await self.request(original_name, data) @@ -283,8 +273,7 @@ class Roundtrip: """Сырой ответ в MinIO — склейка ответа эмулятора (D-4); входящий файл `.pdf` = объект S3. Без очистки (`SCAN_CLEANUP=false`) каталог ответа и входящий файл (с суффиксом `.done` эмулятора) остаются; при - очистке (`SCAN_CLEANUP=true`) во входящем каталоге пусто, каталога `Output//` нет. `.part` в `Tmp` нет - в обоих режимах. + очистке (`SCAN_CLEANUP=true`) во входящем каталоге пусто, каталога `Output//` нет. """ job_id = body["job_id"] assert result.body["file_id"] == body["file_id"] @@ -297,7 +286,6 @@ class Roundtrip: assert entry["response"]["file_uid"] == job_id stat = await self.s3.client.stat_object(self.s3.bucket, f"raw/{job_id}.json") assert stat.content_type == JSON_CONTENT_TYPE - assert self.stray_parts() == [] if cleanup: assert list(self.input_dir.iterdir()) == [] 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, input_dir=settings.SCAN_INPUT_DIR, output_dir=settings.SCAN_OUTPUT_DIR, - tmp_dir=settings.SCAN_TMP_DIR, + tmp_dir="Tmp", # служебный каталог эмулятора; у шлюза его нет (change hotfolder-direct-write) fixtures_dir=fixtures, delay=EMULATOR_DELAY, 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 list(roundtrip_cleanup.input_dir.iterdir()) == [] assert not roundtrip_cleanup.answer_dir(job_id).exists() - assert roundtrip_cleanup.stray_parts() == [] diff --git a/api/tests/unit/conftest.py b/api/tests/unit/conftest.py index 83d676b..ccb0fb1 100644 --- a/api/tests/unit/conftest.py +++ b/api/tests/unit/conftest.py @@ -58,7 +58,6 @@ async def make_env(tmp_path: Path) -> AsyncIterator[EnvFactory]: root, settings.SCAN_INPUT_DIR, settings.SCAN_OUTPUT_DIR, - settings.SCAN_TMP_DIR, settle_seconds=settings.SCAN_SETTLE_SECONDS, cleanup=settings.SCAN_CLEANUP, ) diff --git a/api/tests/unit/test_config.py b/api/tests/unit/test_config.py index 1f29787..04d29e5 100644 --- a/api/tests/unit/test_config.py +++ b/api/tests/unit/test_config.py @@ -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_INPUT_DIR == "Input" 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_POLL_SECONDS == 5 - assert s.SCAN_TIMEOUT_SECONDS == 300 + assert s.SCAN_TIMEOUT_SECONDS == 1200 assert s.SCAN_MAX_INFLIGHT == 4 assert s.OPENBAO.SMB.SMB_HOST == "" assert s.OPENBAO.NATS.NATS_SEED == "" @@ -80,6 +80,16 @@ def test_negative_settle_seconds_rejected(monkeypatch): 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"]) def test_settle_not_less_than_timeout_rejected(monkeypatch, settle): """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_POLL_SECONDS == 0.1 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.OPENBAO.MINIO.MINIO_URL == "127.0.0.1:9000" 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_INPUT_DIR == "Input" - assert s.SCAN_TMP_DIR == "Tmp" + assert not hasattr(s, "SCAN_TMP_DIR") assert s.SCAN_SETTLE_SECONDS == 2 assert s.OPENBAO.MINIO.MINIO_BUCKET == "arm-ka" assert s.OPENBAO.NATS.NATS_JWT == "" diff --git a/api/tests/unit/test_hotfolder_factory.py b/api/tests/unit/test_hotfolder_factory.py index b0bc971..75d6046 100644 --- a/api/tests/unit/test_hotfolder_factory.py +++ b/api/tests/unit/test_hotfolder_factory.py @@ -20,7 +20,6 @@ def _local_settings(root: Path) -> Settings: "SCAN_HOTFOLDER_MODE": "local", "SCAN_HOTFOLDER_PATH": str(root), "SCAN_INPUT_DIR": "In", - "SCAN_TMP_DIR": "Служебный", "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.input_dir == tmp_path / "In" 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.healthy() is True diff --git a/api/tests/unit/test_local_hotfolder.py b/api/tests/unit/test_local_hotfolder.py index a76bb25..910ede2 100644 --- a/api/tests/unit/test_local_hotfolder.py +++ b/api/tests/unit/test_local_hotfolder.py @@ -1,6 +1,6 @@ """Горячая папка в обычном каталоге (spec image-recognition-gateway, D-1, R-4/R-5, slice-0-skeleton). -Быстрые тесты на `tmp_path`: атомарная запись входящего файла через служебный каталог `Tmp`, опрос набора ответов ИС +Быстрые тесты на `tmp_path`: запись входящего файла прямо в `Input` с перезаписью, опрос набора ответов ИС с готовностью по стабилизации (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 +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: def test_job_id_and_lowercase_extension(self) -> None: job_id = _job_id() @@ -57,34 +68,12 @@ class TestInboxName: 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: def _fill(self, hf: LocalHotFolder, job_id: str) -> None: hf.put(f"{job_id}.pdf", b"x") (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"{}", name=f"{job_id}_ofr.json") (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 not (hf.input_dir / f"{job_id}.pdf").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 hf.cleanup(job_id, f"{job_id}.pdf") is True # повтор — не ошибка @@ -181,60 +170,42 @@ class TestPut: hf.put(name, 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) - 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") - assert hf.input_dir.is_dir() - assert hf.tmp_dir.is_dir() - assert not hf.output_dir.exists() + assert sorted(p.name for p in tmp_path.iterdir()) == ["Input"] # Tmp не создаётся - def test_existing_tmp_dir_is_fine(self, tmp_path: Path) -> None: - (tmp_path / "Tmp").mkdir() - LocalHotFolder(tmp_path).put("a.pdf", b"1") - assert (tmp_path / "Input" / "a.pdf").read_bytes() == b"1" + def test_existing_file_is_overwritten(self, tmp_path: Path) -> None: + """Как в старом АРМ (решение владельца 09.10.2026): повтор задания перезаписывает входящий файл.""" + hf = LocalHotFolder(tmp_path, settle_seconds=0) + 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: - hf = LocalHotFolder(tmp_path, tmp_dir="Служебный") - hf.put("a.pdf", b"1") - assert (tmp_path / "Служебный").is_dir() - assert not (tmp_path / "Tmp").exists() + def test_write_failure_keeps_partial_file_when_cleanup_disabled( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + hf = LocalHotFolder(tmp_path, settle_seconds=0) + 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: (tmp_path / "Input").mkdir() @@ -285,227 +256,6 @@ class TestPut: hf.put(name, b"x") 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: """`poll` — все `*.json` каталога `//` по имени, когда набор настоялся `settle_seconds`.""" diff --git a/api/tests/unit/test_worker.py b/api/tests/unit/test_worker.py index 2ae1dac..0be8fc7 100644 --- a/api/tests/unit/test_worker.py +++ b/api/tests/unit/test_worker.py @@ -953,13 +953,14 @@ class TestSlice3a: 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: - """Review Focus 4: входящий файл уже лежит (повтор задания), ответа нет — TIMEOUT, файл не перезаписан.""" + """Входящий файл уже лежит (повтор задания), ответа нет — TIMEOUT; файл перезаписан объектом S3, как в старом + АРМ (change hotfolder-direct-write, решение владельца 09.10.2026).""" env = await make_env() body = env.request(deadline_s=0.3) env.local.put(f"{body['job_id']}.pdf", b"already there") result = await env.run(body) 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"] async def test_ocr_normalizer_produces_documents(self, make_env: EnvFactory) -> None: -- 2.47.2 From 3413328f023ccea537bcfaadaf421c70b7d2bf17 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 9 Oct 2026 16:41:32 +0400 Subject: [PATCH 5/6] =?UTF-8?q?docs:=20=D0=BF=D1=80=D1=8F=D0=BC=D0=B0?= =?UTF-8?q?=D1=8F=20=D0=B7=D0=B0=D0=BF=D0=B8=D1=81=D1=8C=20=D0=B2=20Input,?= =?UTF-8?q?=20SCAN=5FTMP=5FDIR=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20?= =?UTF-8?q?=D1=83=20=D1=8D=D0=BC=D1=83=D0=BB=D1=8F=D1=82=D0=BE=D1=80=D0=B0?= =?UTF-8?q?,=20=D1=82=D0=B0=D0=B9=D0=BC=D0=B0=D1=83=D1=82=201200=20=D1=81?= =?UTF-8?q?=20(#26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 12 ++-- README.md | 56 ++++++++----------- api/.env.example | 14 ++--- .../changes/hotfolder-direct-write/tasks.md | 16 +++--- 4 files changed, 45 insertions(+), 53 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 431bd42..36608b2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,12 +4,12 @@ ## Что это Адаптер внешней ИС Сканирование (Content AI) для PDF/PNG/JPG: положить файл во «Входящие» как `.` -(расширение `original_name` в нижнем регистре; `.part` пишется в `SCAN_TMP_DIR`, по умолчанию `Tmp`, на той же ФС/шаре и -публикуется **без замены**), забрать **все** `Исходящие//*.json` (ИС кладёт по одному json на каждый +(расширение `original_name` в нижнем регистре; пишется **сразу** открытием `wb`, существующий файл перезаписывается — как в +старом АРМ), забрать **все** `Исходящие//*.json` (ИС кладёт по одному json на каждый распознанный документ), склеить и сохранить сырой ответ в S3 `raw/{job_id}.json` (в БД не регистрируется), очистить горячую папку (только при `SCAN_CLEANUP=true`), нормализовать в `../docs/contracts/recognition-result.schema.json` (`statementDocument` v1.2: по одному документу на таблицу `table_structure[i]`), опубликовать `files.tabular_parsed` -(`done` / `needs_check` / `error`). Таймаут → ошибка `TIMEOUT` по `min(reply_deadline, старт + SCAN_TIMEOUT_SECONDS=300)`, +(`done` / `needs_check` / `error`). Таймаут → ошибка `TIMEOUT` по `min(reply_deadline, старт + SCAN_TIMEOUT_SECONDS=1200)`, старт — запись файла во «Входящие». **Нет доступа к БД.** ## Правила @@ -28,9 +28,9 @@ и golden. - Эталоны нормализатора лежат в **fixtures** (`../fixtures/ocr/expected_normalize/`): `make golden-update`, diff смотреть в fixtures и объяснять; эталоны меняются отдельным PR fixtures. -- Горячие папки (раскладка FileWorker): имя `.`, `.part` только в `Tmp`, запись без замены (`os.link` / - `smbclient.rename`; существующее назначение = «доставлено»). `.part`, ставший целью публикации, снимается самой записью - независимо от флага (№26); любое другое удаление (ответ, входящий файл, `.done`, осиротевшие `.part`) — только при +- Горячие папки (раскладка FileWorker): имя `.`, запись сразу в `Input` открытием `wb` с перезаписью, без + временного файла и `Tmp` — как в старом АРМ (change `hotfolder-direct-write`, ASSUMPTION(OQ-79)); `Tmp` — только у + эмулятора. Любое удаление (ответ, входящий файл, `.done`, недописанный при ошибке записи входящий файл) — только при `SCAN_CLEANUP=true` и только после сохранения `raw/` (не при `TIMEOUT`, `FILE_READ_ERROR`, ошибке записи во входящий каталог или `raw/`). Ошибка удаления — одна строка лога, конверт не меняется. Нет расширения у `original_name` → `RECOGNITION_FAILED`. - Набор ответа готов по settle: есть `*.json`, а имена и размеры не менялись `SCAN_SETTLE_SECONDS` (по умолчанию 2; diff --git a/README.md b/README.md index 997130a..340d7b2 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ files.img_uploaded (NATS, queue group scan-gateway) 2. ack: files.tabular_parsed, status=processing (до чтения S3 и до ожидания слота) 3. слот (SCAN_MAX_INFLIGHT одновременных заданий) 4. файл из S3 (s3.bucket/s3.key) - 5. горячая папка: /. (через /...part, без замены) + 5. горячая папка: /. (сразу, wb, существующий перезаписывается) 6. опрос // каждые SCAN_POLL_SECONDS до дедлайна; набор *.json готов, когда не менялся SCAN_SETTLE_SECONDS 7. склейка всех *.json каталога ──► S3 raw/{job_id}.json (до нормализации) @@ -60,19 +60,15 @@ ack (с `attempt` повторного запроса); файл в ИС вто `original_name` запроса (разделители пути `/` и `\` учитываются), в нижнем регистре, 1–16 букв и цифр ASCII (`aep_5.PDF` → `.pdf`): ИС различает форматы по расширению, а имя файла от пользователя в горячую папку не попадает. Нет допустимого расширения — `RECOGNITION_FAILED` без записи в горячую папку. -- Запись атомарна и идёт **без замены**: сначала временный файл `...part` в служебном каталоге - `` (по умолчанию `Tmp`), затем он публикуется в `Input` под настоящим именем. `Tmp` лежит рядом с `Input` - и `Output`, на той же ФС/шаре: публикация атомарна, а ИС не может забрать недописанный файл (временных файлов в - `Input` нет — решение владельца 06.10.2026, у FileWorker `.tmp` лежит рядом с назначением). В режиме `local` публикация — - жёсткая ссылка `.part` → `Input/<имя>` и удаление `.part`; на файловой системе без жёстких ссылок - (`EPERM`/`EXDEV`/`ENOTSUP`/`EOPNOTSUPP` от самой ссылки) — `os.replace`, если назначения ещё нет (решение №17). - `.part` создаётся заново: остаток прежней записи удаляется (ошибка удаления подавляется), файл открывается с `O_EXCL`; - поверх оставшегося `.part` пишем только если это не жёсткая ссылка (`st_nlink == 1`) — иначе исключение (решение №32), - чтобы не испортить уже опубликованный файл. В режиме `smb` — `smbclient.rename` (поднимает `FileExistsError`, если - назначение есть). Если `Input/.` уже существует (повторная публикация задания после потери ack в другом - процессе), файл считается доставленным: ничего не пишется, назначение не перезаписывается, `.part` не трогается (при - `SCAN_CLEANUP=true` существующий `.part` снимается), задание продолжается опросом `Output//` (решение №35). Каталоги `Input` и `Tmp` - создаются при необходимости (без родителей), корень горячей папки сам не создаётся: нет корня — `RECOGNITION_FAILED`. +- Запись — **сразу в `Input`**, как в старом АРМ (решение владельца 09.10.2026, change `hotfolder-direct-write`): + файл открывается на запись с усечением (`wb`) и пишется целиком; временного файла, служебного каталога и + переименования нет. В режиме `smb` — `smbclient.open_file(\<имя>, "wb")`: файл открыт монопольно, пока идёт + запись, и ИС его не прочитает (ASSUMPTION(OQ-79) — так работал старый АРМ). Если `Input/.` уже существует + (повтор задания ядром после потери ack в другом процессе), он **перезаписывается** содержимым объекта S3, задание + продолжается опросом `Output//`. Ошибка посреди записи → `RECOGNITION_FAILED`; недописанный файл снимается + только при `SCAN_CLEANUP=true` (иначе остаётся в `Input`). Каталог `Input` создаётся при необходимости (без + родителей; отказ в правах при существующем каталоге — не ошибка), корень горячей папки сам не создаётся: нет корня — + `RECOGNITION_FAILED`. - Ответ ИС — каталог `//`: ИС кладёт туда **все** распознанные документы задания одновременно, по одному `*.json` на файл (`Output//*.json`). Шлюз читает **все** `*.json` каталога и склеивает их в один ответ; прочие файлы каталога игнорируются. `job_id` проверяется на безопасность пути. В одном json может быть @@ -96,24 +92,19 @@ ack (с `attempt` повторного запроса); файл в ИС вто - **Когда.** Воркер вызывает `HotFolder.cleanup` сразу после того, как набор ответа прочитан, склеен и сохранён в `raw/{job_id}.json` — при любом исходе разбора и нормализации (в том числе когда все файлы набора — ошибки ИС). - **Что удаляется** (всё, что есть): **всё содержимое** каталога `Output//` (не только `*.json`) и сам каталог, - `Input/.`, `Input/..done` (след эмулятора), `Tmp/...part`. Отсутствующий файл + `Input/.`, `Input/..done` (след эмулятора). `Tmp` шлюз не трогает. Отсутствующий файл или каталог не ошибка. В режиме `local` каталог ответа удаляется рекурсивно; в режиме `smb` удаляются файлы каталога, а подкаталог внутри `Output//` не даст снять каталог (`rmdir` → `STATUS_DIRECTORY_NOT_EMPTY`): ошибка очистки - поднимается и логируется, остальные пункты (входящий файл, `.done`, `.part`) в этом задании не удаляются. + поднимается и логируется, остальные пункты (входящий файл, `.done`) в этом задании не удаляются. - **Когда не вызывается:** `TIMEOUT` (ИС может ещё писать ответ), `FILE_READ_ERROR`, `RECOGNITION_FAILED` записи во входящий каталог, ошибка записи `raw/` в S3. Входящий файл и каталог ответа тогда остаются. - **Ошибки удаления** (например, `PermissionError` на шаре): одна строка в логе (`warning`) на задание; конверт, `metrics.warnings` и `/healthcheck` не меняются — исход задания определён разбором ответа. -- **`.part` и флаг (решение №26).** `.part`, ставший целью успешной публикации, снимается **самой записью**, независимо - от `SCAN_CLEANUP` (в `local` — удаление после жёсткой ссылки, ошибка удаления подавляется; в `smb` `.part` - переименовывается во входящий и отдельного удаления не требует): иначе при выключенной очистке на каждое задание - оставалась бы лишняя ссылка, а ошибка удаления на шаре с неподтверждёнными правами превращала бы доставленный файл в - `RECOGNITION_FAILED`. **Флаг управляет остатками:** осиротевшие `.part` (оборванная запись, `.part` при уже - существующем назначении), каталог ответа, входящий файл и `.done`. При `SCAN_CLEANUP=false` сервис не удаляет ничего из - этого и не пишет в лог об осиротевших `.part` (решение №27): они накапливаются в `Tmp` и убираются вручную или - включением флага. -- Права на SMB: для очистки учётной записи шары нужны права на удаление в `Input`, `Output` (включая каталог - ``) и `Tmp`; для записи — создание файлов в `Tmp` и `Input` и переименование. Пока права на удаление не +- **Флаг управляет всем удалением:** при `SCAN_CLEANUP=false` сервис не удаляет ничего — ни каталог ответа, ни входящий + файл и `.done`, ни недописанный при ошибке записи входящий файл — и не пишет об этом в лог (решение №27). Остатки + `Tmp/.*.part` от версий шлюза до change `hotfolder-direct-write` сервис не трогает — убрать вручную. +- Права на SMB: для записи учётной записи шары нужно создание и перезапись файлов в `Input`, для чтения ответа — + листинг и чтение `Output`; для очистки — удаление в `Input` и `Output` (включая каталог ``). Пока права на удаление не подтверждены, оставляйте `SCAN_CLEANUP=false`. ### Эмулятор ИС в режиме `local` @@ -315,12 +306,11 @@ pydantic-валидацией; битый файл (код не из 4–5 ци | `SCAN_HOTFOLDER_PATH` | пусто | корень горячей папки в режиме `local` (обязателен, каталог должен существовать) | | `SCAN_INPUT_DIR` | `Input` | «Входящие» относительно корня | | `SCAN_OUTPUT_DIR` | `Output` | «Исходящие» относительно корня | -| `SCAN_TMP_DIR` | `Tmp` | служебный каталог относительно корня (на той же ФС/шаре, что `Input`/`Output`): временный `...part` записи входящего файла | | `SCAN_POLL_SECONDS` | `5` | период опроса «Исходящих», секунды | | `SCAN_SETTLE_SECONDS` | `2` | набор `*.json` в `Output//` готов, когда имена и размеры не менялись столько секунд, считая от первого опроса, увидевшего окончательный набор (>= 0; `0` — сразу) | -| `SCAN_TIMEOUT_SECONDS` | `300` | потолок ожидания ответа ИС, секунды (дополнительно к `reply_deadline`) | +| `SCAN_TIMEOUT_SECONDS` | `1200` | потолок ожидания ответа ИС, секунды (дополнительно к `reply_deadline`; 20 мин — в паре со сроком ответа OCR ядра) | | `SCAN_MAX_INFLIGHT` | `4` | одновременно обрабатываемых заданий | -| `SCAN_CLEANUP` | `false` | очистка горячей папки после чтения набора ответа: удаляет всё содержимое `Output//` и каталог, `Input/.` (и `.done`), `Tmp/...part`; `false` — ничего не удаляется (кроме `.part`, снятого самой записью); см. «Очистка горячей папки» | +| `SCAN_CLEANUP` | `false` | очистка горячей папки после чтения набора ответа: удаляет всё содержимое `Output//` и каталог, `Input/.` (и `.done`); `false` — ничего не удаляется; см. «Очистка горячей папки» | | `SCAN_RULES_DIR` | `rules` | каталог справочников нормализатора (`statement_lines.yaml`, `ocr_lookalikes.yaml`, `statement_labels.yaml`), путь от текущего каталога (`api/`); битый справочник — сервис не стартует | | `SERVICE_NAME` | `scan-gateway` | `service.name` в конверте | | `VERSION` | `1.0.0` | версия сервиса (`/healthcheck`, `service.version`) | @@ -335,7 +325,8 @@ MinIO и NATS); те же значения подхватываются из `EN | Переменная | По умолчанию | Назначение | |---|---|---| | `SCAN_HOTFOLDER_PATH` | — (обязательна) | корень горячей папки; должен существовать, иначе код выхода 2 | -| `SCAN_INPUT_DIR`, `SCAN_OUTPUT_DIR`, `SCAN_TMP_DIR` | `Input`, `Output`, `Tmp` | подкаталоги корня (как у шлюза); в `Tmp` эмулятор собирает набор ответа перед rename в `Output//` | +| `SCAN_INPUT_DIR`, `SCAN_OUTPUT_DIR` | `Input`, `Output` | подкаталоги корня (как у шлюза) | +| `SCAN_TMP_DIR` | `Tmp` | служебный каталог эмулятора: в нём собирается набор ответа перед rename в `Output//` (шлюз эту переменную не читает) | | `SCAN_FIXTURES_DIR` | `../../fixtures/ocr` | каталог эталонных пар: исходник (PDF и др.) и ответы ИС (`aep_N.json`, `aep_N_<суффикс>.json`); пара ищется по sha256 содержимого входящего файла; путь от `api/` | | `SCAN_EMULATOR_DEFAULT` | пусто | основа имени набора (например, `aep_6`), который отдаётся при промахе по хешу; пусто или набора нет — ответ-ошибка `INVALID_DOCUMENT` | | `SCAN_EMULATOR_DELAY` | `2` | задержка перед выдачей ответа, секунды (>= 0) | @@ -508,8 +499,9 @@ make golden-update # пересобрать эталоны нормализ - Недописанный набор ответа ИС: защита — settle (`SCAN_SETTLE_SECONDS`), а не разбор содержимого. Если ИС дописывает файл дольше settle, будет принят неполный набор; файл, не разобравшийся как JSON, после склейки не перечитывается: `response = null`, `parse_error` в `raw/`, ошибка этого файла (`INVALID_FORMAT`) — в `metrics.warnings`. -- Запись без замены на файловой системе без жёстких ссылок (`EPERM`/`EXDEV`/`ENOTSUP`): между проверкой назначения и - `os.replace` есть крошечное окно гонки (повтор задания с тем же `job_id` редок, решение №17). +- Запись сразу в `Input` (change `hotfolder-direct-write`): защита от чтения недописанного файла — только монопольное + открытие `smbclient` на время записи (ASSUMPTION(OQ-79), как в старом АРМ). Если ИС всё же возьмёт файл во время + записи, распознавание даст ошибку; откат — вернуть запись через временный файл. - Эмулятор: повреждённая фикстура (не JSON, не UTF-8 или корень не объект) не останавливает его — на её месте в наборе он кладёт ответ `INVALID_DOCUMENT` с сообщением о повреждённой фикстуре и пишет ошибку в лог. Индекс хешей пересчитывается при изменении списка файлов каталога фикстур: изменённый, но не переименованный PDF не diff --git a/api/.env.example b/api/.env.example index 9005d7d..2808c3d 100644 --- a/api/.env.example +++ b/api/.env.example @@ -43,19 +43,17 @@ SCAN_HOTFOLDER_PATH=./.hotfolder # Подкаталоги корня: «Входящие» (сюда кладётся UUID_имя) и «Исходящие» (оттуда забираются все *.json из /) SCAN_INPUT_DIR=Input SCAN_OUTPUT_DIR=Output -# Служебный каталог корня: временный .part пишется сюда и переименовывается во «Входящие» (та же ФС/шара — rename -# атомарен, во «Входящих» не бывает недописанных файлов); создаётся при первой записи -SCAN_TMP_DIR=Tmp # Период опроса «Исходящих», секунды SCAN_POLL_SECONDS=5 # Набор *.json в / считается готовым, когда имена и размеры файлов не менялись столько секунд (>= 0) SCAN_SETTLE_SECONDS=2 -# Потолок ожидания ответа ИС, секунды (дополнительно ограничен reply_deadline запроса); повторной отправки нет -SCAN_TIMEOUT_SECONDS=300 +# Потолок ожидания ответа ИС, секунды (дополнительно ограничен reply_deadline запроса; 20 мин — в паре со сроком +# ответа OCR ядра); повторной отправки нет +SCAN_TIMEOUT_SECONDS=1200 # Сколько заданий обрабатывается одновременно SCAN_MAX_INFLIGHT=4 # Очистка горячей папки после прочтения ответа (D-2): true — удалить всё содержимое Output//, входящий файл -# (и его .done), осиротевший .part в Tmp; false (по умолчанию) — ничего не удалять +# (и его .done); false (по умолчанию) — ничего не удалять SCAN_CLEANUP=false # Каталог справочников нормализатора OCR (statement_lines.yaml, ocr_lookalikes.yaml, statement_labels.yaml); # относительный путь — от каталога запуска (api/); битый справочник — сервис не стартует @@ -70,7 +68,9 @@ SCAN_RULES_DIR=rules # --- Эмулятор ИС Сканирование (python -m src.scan_emulator, make emulator; только dev) --- # Эти параметры читает сам эмулятор, сервис шлюза их не использует. Корень горячей папки и подкаталоги # эмулятор берёт из SCAN_HOTFOLDER_PATH / SCAN_INPUT_DIR / SCAN_OUTPUT_DIR выше; набор ответа на задание он собирает -# во временном каталоге SCAN_TMP_DIR (выше) и переименовывает в // целиком. +# во временном каталоге SCAN_TMP_DIR и переименовывает в // целиком (шлюз SCAN_TMP_DIR не читает: файл +# во «Входящие» он пишет сразу, как в старом АРМ). +SCAN_TMP_DIR=Tmp # Каталог с фикстурами ответов ИС (aep_N.json); путь от api/ (репозиторий fixtures лежит рядом с workspace) SCAN_FIXTURES_DIR=../../fixtures/ocr # Задержка перед выдачей ответа, секунды (имитация работы ИС) diff --git a/openspec/changes/hotfolder-direct-write/tasks.md b/openspec/changes/hotfolder-direct-write/tasks.md index 9e4b52e..d8f0b16 100644 --- a/openspec/changes/hotfolder-direct-write/tasks.md +++ b/openspec/changes/hotfolder-direct-write/tasks.md @@ -1,18 +1,18 @@ ## 1. Тесты (scan-gateway, `api/tests/`) -- [ ] 1.1 `unit/test_smb_hotfolder.py`: `put` пишет `open_file(\<имя>, "wb")` без `Tmp`/`rename`; существующий +- [x] 1.1 `unit/test_smb_hotfolder.py`: `put` пишет `open_file(\<имя>, "wb")` без `Tmp`/`rename`; существующий файл перезаписывается; ошибка записи — снятие файла только при `cleanup=True`; `cleanup` не трогает `.part` -- [ ] 1.2 `unit/test_local_hotfolder.py`: то же для `LocalHotFolder`; `Tmp` не создаётся `put` -- [ ] 1.3 `unit/test_config.py`, `unit/test_hotfolder_factory.py`: нет `SCAN_TMP_DIR` у шлюза, `SCAN_TIMEOUT_SECONDS` +- [x] 1.2 `unit/test_local_hotfolder.py`: то же для `LocalHotFolder`; `Tmp` не создаётся `put` +- [x] 1.3 `unit/test_config.py`, `unit/test_hotfolder_factory.py`: нет `SCAN_TMP_DIR` у шлюза, `SCAN_TIMEOUT_SECONDS` по умолчанию 1200; интеграционные `test_roundtrip.py` — без ожиданий про `Tmp/.part` ## 2. Реализация (scan-gateway, `api/src/`) -- [ ] 2.1 `gateway/smb.py`: `put` — прямая запись (D-1, D-2, D-5), убрать `tmp_dir`, `.part` из `cleanup` -- [ ] 2.2 `gateway/hotfolder.py`: `LocalHotFolder.put` — прямая запись; `tmp_dir` только для эмулятора (D-3) -- [ ] 2.3 `core/config.py`, `gateway/__init__.py`: убрать `SCAN_TMP_DIR` у шлюза; `SCAN_TIMEOUT_SECONDS = 1200` +- [x] 2.1 `gateway/smb.py`: `put` — прямая запись (D-1, D-2, D-5), убрать `tmp_dir`, `.part` из `cleanup` +- [x] 2.2 `gateway/hotfolder.py`: `LocalHotFolder.put` — прямая запись; `tmp_dir` только для эмулятора (D-3) +- [x] 2.3 `core/config.py`, `gateway/__init__.py`: убрать `SCAN_TMP_DIR` у шлюза; `SCAN_TIMEOUT_SECONDS = 1200` ## 3. Документация -- [ ] 3.1 `README.md`, `CLAUDE.md`, `api/.env.example` — прямая запись, `SCAN_TMP_DIR` только у эмулятора, таймаут 1200 -- [ ] 3.2 `make check-full` +- [x] 3.1 `README.md`, `CLAUDE.md`, `api/.env.example` — прямая запись, `SCAN_TMP_DIR` только у эмулятора, таймаут 1200 +- [x] 3.2 `make check-full` -- 2.47.2 From 7d8a506f4de7b5b386d02cf76f6e0a35b08db980 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 9 Oct 2026 16:54:54 +0400 Subject: [PATCH 6/6] =?UTF-8?q?fix(hotfolder):=20=D0=BE=D1=88=D0=B8=D0=B1?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=BE=D1=82=D0=BA=D1=80=D1=8B=D1=82=D0=B8=D1=8F?= =?UTF-8?q?=20=D0=B2=D1=85=D0=BE=D0=B4=D1=8F=D1=89=D0=B5=D0=B3=D0=BE=20?= =?UTF-8?q?=D1=84=D0=B0=D0=B9=D0=BB=D0=B0=20=D0=BD=D0=B5=20=D1=83=D0=B4?= =?UTF-8?q?=D0=B0=D0=BB=D1=8F=D0=B5=D1=82=20=D1=84=D0=B0=D0=B9=D0=BB=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D1=88=D0=BB=D0=BE=D0=B9=20=D0=B4=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D0=B2=D0=BA=D0=B8=20(#26,=20I-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Открытие вынесено из блока снятия недописанного файла: при SCAN_CLEANUP=true удаляется только файл, сбойнувший после открытия. Решения R-5…R-8 — в design.md. Co-Authored-By: Claude Opus 5.5 --- api/src/gateway/hotfolder.py | 7 ++- api/src/gateway/smb.py | 6 ++- api/tests/unit/test_local_hotfolder.py | 46 +++++++++++++++---- api/tests/unit/test_smb_hotfolder.py | 45 ++++++++++++++++-- .../changes/hotfolder-direct-write/design.md | 4 ++ 5 files changed, 92 insertions(+), 16 deletions(-) diff --git a/api/src/gateway/hotfolder.py b/api/src/gateway/hotfolder.py index e1a2a01..cbc554b 100644 --- a/api/src/gateway/hotfolder.py +++ b/api/src/gateway/hotfolder.py @@ -204,12 +204,15 @@ class LocalHotFolder: def put(self, name: str, data: bytes) -> None: """Записать `input_dir/` сразу (`wb`, перезапись существующего, как в старом АРМ — решение владельца 09.10.2026). `Input` создаётся без родителей, корень — нет (нет корня — `FileNotFoundError`). При ошибке записи - недописанный файл снимается только при включённой очистке (R-3), ошибка снятия подавляется.""" + недописанный файл снимается только при включённой очистке (R-3), ошибка снятия подавляется. Ошибка самого + открытия ничего не удаляет: на месте может лежать целый файл прошлой доставки (I-1).""" validate_name(name) self.input_dir.mkdir(exist_ok=True) target = self.input_dir / name + handle = open(target, "wb") # noqa: SIM115 — открытие вне try: его ошибка не снимает чужой файл (I-1) try: - target.write_bytes(data) + with handle: + handle.write(data) except BaseException: self._drop_partial(target) raise diff --git a/api/src/gateway/smb.py b/api/src/gateway/smb.py index 5f2c62f..5699b3d 100644 --- a/api/src/gateway/smb.py +++ b/api/src/gateway/smb.py @@ -104,13 +104,15 @@ class SmbHotFolder: файла и rename нет, существующий файл перезаписывается. `smbclient.open_file` открывает файл монопольно — пока запись идёт, ИС его не прочитает (ASSUMPTION(OQ-79)). Имя проверяется до любого обращения к шаре. При ошибке записи недописанный файл снимается только при включённой очистке (`SCAN_CLEANUP`, R-3); ошибка снятия исходную - не маскирует. Нет корня — `SMBOSError` (ENOENT) от `mkdir`. + не маскирует. Ошибка самого открытия (файл занят ИС, нет прав) ничего не удаляет: недописанного файла нет, а на + месте может лежать целый файл прошлой доставки (I-1). Нет корня — `SMBOSError` (ENOENT) от `mkdir`. """ validate_name(name) self._ensure_dir(self.input_dir) target = ntpath.join(self.input_dir, name) + fd = smbclient.open_file(target, mode="wb", **self._credentials) try: - with smbclient.open_file(target, mode="wb", **self._credentials) as fd: + with fd: fd.write(data) except BaseException: self._drop_partial(target) diff --git a/api/tests/unit/test_local_hotfolder.py b/api/tests/unit/test_local_hotfolder.py index 910ede2..c85c8e4 100644 --- a/api/tests/unit/test_local_hotfolder.py +++ b/api/tests/unit/test_local_hotfolder.py @@ -12,6 +12,7 @@ from typing import Any import pytest +from src.gateway import hotfolder as hotfolder_module from src.gateway.hotfolder import AnswerFile, HotFolder, LocalHotFolder, SettleTracker, inbox_name @@ -41,11 +42,25 @@ def _settled(tmp_path: Path, settle: float = 2) -> tuple[LocalHotFolder, FakeClo 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, "диск полон") +class _FailingFile: + """Файл, открытый на запись: `write` пишет первые два байта и падает (диск полон).""" + + def __init__(self, path: Any, mode: str) -> None: + self._handle = open(path, mode) # noqa: SIM115 — закрывается в __exit__ + + def __enter__(self) -> "_FailingFile": + return self + + def __exit__(self, *exc: object) -> None: + self._handle.close() + + def write(self, data: bytes) -> int: + self._handle.write(data[:2]) + raise OSError(errno.ENOSPC, "диск полон") + + +def _failing_open(path: Any, mode: str = "r", *args: Any, **kwargs: Any) -> _FailingFile: + return _FailingFile(path, mode) def _failing_unlink(self: Path, missing_ok: bool = False) -> None: @@ -186,7 +201,7 @@ class TestPut: self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: hf = LocalHotFolder(tmp_path, settle_seconds=0) - monkeypatch.setattr(Path, "write_bytes", _failing_write) + monkeypatch.setattr(hotfolder_module, "open", _failing_open, raising=False) with pytest.raises(OSError, match="диск полон"): hf.put("job.pdf", b"data") assert (hf.input_dir / "job.pdf").read_bytes() == b"da" @@ -195,18 +210,33 @@ class TestPut: 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(hotfolder_module, "open", _failing_open, raising=False) 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(hotfolder_module, "open", _failing_open, raising=False) monkeypatch.setattr(Path, "unlink", _failing_unlink) with pytest.raises(OSError, match="диск полон"): hf.put("job.pdf", b"data") + @pytest.mark.parametrize("cleanup", [False, True]) + def test_open_failure_never_removes_existing_file(self, tmp_path: Path, cleanup: bool) -> None: + """I-1: открыть не удалось — недописанного файла нет, целый файл прошлой доставки не трогаем.""" + hf = LocalHotFolder(tmp_path, settle_seconds=0, cleanup=cleanup) + hf.input_dir.mkdir() + delivered = hf.input_dir / "job.pdf" + delivered.write_bytes(b"delivered") + delivered.chmod(0o444) + try: + with pytest.raises(PermissionError): + hf.put("job.pdf", b"new") + assert delivered.read_bytes() == b"delivered" + finally: + delivered.chmod(0o644) + def test_existing_input_dir_is_fine(self, tmp_path: Path) -> None: (tmp_path / "Input").mkdir() LocalHotFolder(tmp_path).put("a.pdf", b"1") diff --git a/api/tests/unit/test_smb_hotfolder.py b/api/tests/unit/test_smb_hotfolder.py index 5962743..ab725e5 100644 --- a/api/tests/unit/test_smb_hotfolder.py +++ b/api/tests/unit/test_smb_hotfolder.py @@ -170,6 +170,26 @@ def _write_answer(share_root: Path, job_id: str, data: bytes, name: str | None = return path +def _fail_write(fake: FakeSmbClient) -> SMBOSError: + """`open_file` открывает файл, но `write` пишет два байта и падает (диск полон): недописанный файл остаётся.""" + error = SMBOSError(NtStatus.STATUS_DISK_FULL, INPUT) + real_open = fake.open_file + + def open_file(path: str, mode: str = "r", **kwargs: Any) -> IO[Any]: + handle = real_open(path, mode, **kwargs) + + def write(data: bytes) -> int: + handle.raw.write(data[:2]) if hasattr(handle, "raw") else None + handle.flush() + raise error + + handle.write = write # type: ignore[method-assign] + return handle + + fake.open_file = open_file # type: ignore[method-assign] + return error + + class TestPaths: def test_default_layout(self) -> None: hf = _hotfolder() @@ -309,28 +329,45 @@ class TestPut: def test_write_failure_keeps_partial_file_when_cleanup_disabled( self, fake: FakeSmbClient, share_root: Path ) -> None: - fake.fail["open_file"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT) + _fail_write(fake) with pytest.raises(SMBOSError): _hotfolder().put("a.pdf", b"data") + assert (share_root / "Input" / "a.pdf").read_bytes() == b"da" assert "remove" not in fake.funcs() def test_write_failure_removes_partial_file_when_cleanup_enabled( self, fake: FakeSmbClient, share_root: Path ) -> None: - fake.fail["open_file"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT) + _fail_write(fake) with pytest.raises(SMBOSError): _hotfolder(cleanup=True).put("a.pdf", b"data") assert fake.calls[-1] == ("remove", (f"{INPUT}\\a.pdf",), {}) + assert not (share_root / "Input" / "a.pdf").exists() def test_cleanup_error_does_not_mask_original(self, fake: FakeSmbClient, share_root: Path) -> None: - original = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT) - fake.fail["open_file"] = original + original = _fail_write(fake) fake.fail["remove"] = SMBException("соединение потеряно") with pytest.raises(SMBOSError) as excinfo: _hotfolder(cleanup=True).put("a.pdf", b"data") assert excinfo.value is original assert fake.funcs() == ["mkdir", "open_file", "remove"] + @pytest.mark.parametrize("cleanup", [False, True]) + def test_open_failure_never_removes_existing_file( + self, fake: FakeSmbClient, share_root: Path, cleanup: bool + ) -> None: + """I-1: открыть не удалось (например, ИС держит файл повторённого задания) — недописанного файла нет, целый + файл прошлой доставки не трогаем даже при включённой очистке; ошибка открытия поднимается как есть.""" + (share_root / "Input").mkdir() + (share_root / "Input" / "a.pdf").write_bytes(b"delivered") + denied = SMBOSError(NtStatus.STATUS_SHARING_VIOLATION, INPUT + "\\a.pdf") + fake.fail["open_file"] = denied + with pytest.raises(SMBOSError) as excinfo: + _hotfolder(cleanup=cleanup).put("a.pdf", b"new") + assert excinfo.value is denied + assert "remove" not in fake.funcs() + assert (share_root / "Input" / "a.pdf").read_bytes() == b"delivered" + def test_passes_credentials_to_every_call(self, fake: FakeSmbClient, share_root: Path) -> None: _hotfolder(login="user", password="secret").put("a.pdf", b"1") assert [kwargs for _, _, kwargs in fake.calls] == [{"username": "user", "password": "secret"}] * 2 diff --git a/openspec/changes/hotfolder-direct-write/design.md b/openspec/changes/hotfolder-direct-write/design.md index 4d7e198..09eed69 100644 --- a/openspec/changes/hotfolder-direct-write/design.md +++ b/openspec/changes/hotfolder-direct-write/design.md @@ -56,3 +56,7 @@ | R-2 | 2026-10-09 | `Input` по-прежнему создаётся перед записью (D-2), хотя старый АРМ этого не делал | Отказ в правах на `mkdir` при существующем каталоге уже не ошибка; dev и тесты на этом держатся | Лишний вызов `mkdir` на шаре на каждое задание | | R-3 | 2026-10-09 | Ошибка записи: недописанный файл в `Input` удаляется только при `SCAN_CLEANUP=true` (D-5) | Требование «при `SCAN_CLEANUP=false` ничего не удалять» (OQ-144) сохраняется | Обрезанный файл остаётся в `Input`, ИС может его распознать с ошибкой; ядро получит `RECOGNITION_FAILED` от шлюза раньше | | R-4 | 2026-10-09 | `SCAN_TMP_DIR` удаляется из шлюза, у эмулятора остаётся (D-3) | Шлюзу каталог больше не нужен; эмулятору нужен для атомарной сборки ответа | Нет: `extra="ignore"` — старые `.env` стартуют | +| R-5 | 2026-10-09 | Правка вызова `SmbHotFolder` в фабрике (`gateway/__init__.py`) перенесена из задачи 2 плана в задачу 1 | Без неё mypy красный между задачами (лишний позиционный аргумент) | Нет | +| R-6 | 2026-10-09 | Сверх плана: `tests/unit/conftest.py` не передаёт `SCAN_TMP_DIR`; `test_worker.py::test_existing_input_waits_for_answer` ждёт перезаписи объектом S3; в `test_roundtrip.py` эмулятору передаётся литерал `"Tmp"` | Прямые следствия D-1 и D-3 | Нет | +| R-7 | 2026-10-09 | Финальное ревью I-1: ошибка **открытия** входящего файла ничего не удаляет (открытие вынесено из блока снятия недописанного файла); снимается только файл, сбойнувший после открытия | Иначе при `SCAN_CLEANUP=true` удалялся бы целый файл прошлой доставки, который, возможно, читает ИС | Нет | +| R-8 | 2026-10-09 | Финальное ревью I-1(б): если при повторе задания в другом процессе файл занят ИС (`STATUS_SHARING_VIOLATION` на открытии), шлюз публикует `RECOGNITION_FAILED`, а не считает файл доставленным — как старый АРМ (`wb` без особых случаев) | Решение владельца «как в старом АРМ»; повтор в другом процессе — только после потери ack (редко) | Ядро может получить `RECOGNITION_FAILED` раньше успешного ответа первого экземпляра (OCR `max_attempts` = 1) — аналитику придётся перезагрузить файл; вынесено вопросом владельцу | -- 2.47.2