openspec: change hotfolder-direct-write — запись прямо в Input, как в старом АРМ (#26)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
6da4114bde
commit
85f4e9c847
2
openspec/changes/hotfolder-direct-write/.openspec.yaml
Normal file
2
openspec/changes/hotfolder-direct-write/.openspec.yaml
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-10-09
|
||||||
58
openspec/changes/hotfolder-direct-write/design.md
Normal file
58
openspec/changes/hotfolder-direct-write/design.md
Normal 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` стартуют |
|
||||||
43
openspec/changes/hotfolder-direct-write/proposal.md
Normal file
43
openspec/changes/hotfolder-direct-write/proposal.md
Normal 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` от прежних версий шлюз больше не трогает — убрать вручную.
|
||||||
@ -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>/` (если есть) не тронуты
|
||||||
18
openspec/changes/hotfolder-direct-write/tasks.md
Normal file
18
openspec/changes/hotfolder-direct-write/tasks.md
Normal 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`
|
||||||
Loading…
x
Reference in New Issue
Block a user