Горячая папка: запись прямо в Input, как в старом АРМ; таймаут 20 мин (#26) #27

Merged
aep merged 6 commits from feat/26-hotfolder-direct-write into main 2026-10-09 16:22:07 +03:00
5 changed files with 188 additions and 0 deletions
Showing only changes of commit 85f4e9c847 - Show all commits

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-09

View File

@ -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(<Input>\<имя>, mode="wb", **credentials)` и `write`. `LocalHotFolder.put`:
`open(<input>/<имя>, "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` снимает недописанный `<Input>/<имя>` только при включённой очистке
(ошибка снятия подавляется, исходное исключение поднимается) — 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` стартуют |

View File

@ -0,0 +1,43 @@
## Why
Решение владельца 09.10.2026: запись входящего файла в горячую папку ИС Сканирование — «как в старом АРМ», где она
работала в контуре: `smbclient.open_file('<SMB_PATH>/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`): файл `<job_id>.<ext>` пишется сразу во входящий каталог, открытием на запись с
усечением (`wb`); существующий файл **перезаписывается** (как в старом АРМ). Временного файла, каталога `Tmp` и
переименования больше нет.
- Настройка шлюза `SCAN_TMP_DIR` удаляется; параметр `tmp_dir` у `SmbHotFolder` удаляется. У `LocalHotFolder` он остаётся
только для эмулятора (сборка набора ответа, переменная эмулятора `SCAN_TMP_DIR` не меняется).
- Очистка (`SCAN_CLEANUP=true`) больше не удаляет `Tmp/.<job_id>.<ext>.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/<job_id>/*.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` от прежних версий шлюз больше не трогает — убрать вручную.

View File

@ -0,0 +1,67 @@
## MODIFIED Requirements
### Requirement: Передача файла в ИС Сканирование через горячие папки
После подтверждения сервис SHALL прочитать исходник из S3 (`s3.bucket/s3.key`) и записать его во входящий каталог как
`<job_id>.<ext>`, где `<ext>` — расширение `original_name` запроса в нижнем регистре; запрос без расширения SHALL
завершаться конвертом `RECOGNITION_FAILED` без записи в горячую папку. Файл SHALL записываться сразу во входящий каталог
открытием на запись с усечением, как в старом АРМ (решение владельца 09.10.2026): временных файлов, служебного каталога
и переименования нет; если `<Input>/<job_id>.<ext>` уже существует, он SHALL быть перезаписан содержимым объекта S3.
Затем сервис SHALL опрашивать исходящий каталог `<SCAN_OUTPUT_DIR>/<job_id>/` каждые `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 удалить всё содержимое `<SCAN_OUTPUT_DIR>/<job_id>/` (json-файлы ответа и любые
прочие) и сам каталог `<job_id>`, входящий файл `<Input>/<job_id>.<ext>` и его вариант `<Input>/<job_id>.<ext>.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/<job_id>/` (если есть) не тронуты

View File

@ -0,0 +1,18 @@
## 1. Тесты (scan-gateway, `api/tests/`)
- [ ] 1.1 `unit/test_smb_hotfolder.py`: `put` пишет `open_file(<Input>\<имя>, "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`