удалён вход Telegram, владелец записи стал обязателен в схеме
- убраны клиент бота, транспорт обновлений, отправитель сообщений, сборка входа при старте, секция настроек и зависимость go-telegram-bot-api; из конвейера ушла доставка ответа отправителю — исход виден опросом готовности. Колонки адресата и значение источника остались в схеме: применённые шаги не переписываются - шаг 202608140003 запрещает пустого владельца у аудиозаписи и у файла; существующие строки он не проверяет, и это принято сознательно — искать их надо запросом до выкладки - ревью нашло два пред-существующих дефекта, оба закрыты: пустой второй ответ распознавателя стирал сохранённую расшифровку, а пустая расшифровка перестала быть заметной вместе с убранной доставкой. Попутно поднят golang.org/x/image до v0.45.0 — красный шаг vulns, воспроизводился и на чистом master
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: У записи есть владелец, и чужую ей не отдают
|
||||
|
||||
Сервис SHALL заводить у каждой принятой записи владельца — учётную запись, от
|
||||
имени которой запись принята, — и MUST отдавать данные такой записи только её
|
||||
владельцу. Запись без владельца MUST не заводиться ничем — ни приёмом, ни конвейером, ни
|
||||
рукой в панели: колонка владельца пустого значения не принимает, и норму эту
|
||||
держит capability `storage`.
|
||||
|
||||
Владелец назначается один раз, при приёме, и MUST не меняться: совместного
|
||||
доступа, ролей и передачи записи другому сервис не знает.
|
||||
|
||||
Владелец MUST браться из предъявленной сессии и ниоткуда больше. Владелец,
|
||||
пришедший полем запроса, дал бы всякому вошедшему право завести запись на чужое
|
||||
имя.
|
||||
|
||||
Обращение к чужой записи MUST быть неотличимо от обращения к несуществующей.
|
||||
Отдельный отказ «доступ запрещён» превращает опрос в перебор — по разнице
|
||||
ответов считывается, какие записи заведены, а идентификатор записи и есть то,
|
||||
что разграничение прячет. Каким именно ответом это выражено, нормирует
|
||||
capability `intake`: там живёт адрес опроса, и держатель нормы обязан быть один.
|
||||
|
||||
Пустой владелец MUST не совпадать ни с одной записью. Правило записано со стороны
|
||||
**спрашивающего** и остаётся в силе, хотя записей без владельца в хранилище
|
||||
больше нет: спрашивающий с пустым владельцем — это вызов, у которого нет учётной
|
||||
записи, и отвечать ему надо отказом, а не выборкой. Держится оно отдельно от
|
||||
схемы намеренно: схема запрещает **заводить** ничью запись, а это правило
|
||||
запрещает **спрашивать** ничьим именем, и одно другое не заменяет.
|
||||
|
||||
#### Scenario: Своя запись доступна
|
||||
|
||||
- **GIVEN** человек вошёл и принял запись
|
||||
- **WHEN** он спрашивает состояние этой записи своей сессией
|
||||
- **THEN** ответ несёт состояние записи
|
||||
|
||||
#### Scenario: Чужая запись неотличима от несуществующей
|
||||
|
||||
- **GIVEN** запись принята одним вошедшим
|
||||
- **WHEN** её состояние спрашивает другой вошедший
|
||||
- **THEN** ответ тот же, что и на неизвестный идентификатор, — и кодом, и телом
|
||||
|
||||
#### Scenario: Владельца не задают запросом
|
||||
|
||||
- **WHEN** запрос на приём записи несёт своё значение владельца
|
||||
- **THEN** владельцем принятой записи становится предъявитель сессии
|
||||
|
||||
#### Scenario: Ничью запись завести нечем
|
||||
|
||||
- **WHEN** запись пытаются завести с пустым владельцем
|
||||
- **THEN** хранилище её не сохраняет
|
||||
|
||||
#### Scenario: Пустой владелец не открывает ничего
|
||||
|
||||
- **GIVEN** заведены две записи: своя и чужая
|
||||
- **WHEN** состояние каждой спрашивают с пустым владельцем
|
||||
- **THEN** ответ на обе тот же, что и на неизвестный идентификатор
|
||||
@@ -0,0 +1,245 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Приём записи по HTTP
|
||||
|
||||
Сервис SHALL принимать запись от внешней программы запросом `POST /api/audio` с
|
||||
телом `multipart/form-data` и полем `audio` **только от узнанного отправителя**.
|
||||
Запрос без сессии MUST получать код `401`, и по нему MUST не заводиться ни файл,
|
||||
ни аудиозапись. Принятая запись от узнанного отправителя MUST быть сохранена и
|
||||
получить заведённую под неё аудиозапись на рубеже `uploaded`; ответ MUST нести
|
||||
идентификатор записи полем `job_id` и её рубеж полем `status`.
|
||||
|
||||
Значение рубежа в ответе изменилось: прежде приём отдавал `created`. Перечень
|
||||
состояний назван проектом необратимым, и ломка объявлена прямо — состояние
|
||||
теперь называет достигнутое, а не предстоящее, и `created` в новом перечне нет
|
||||
вовсе.
|
||||
|
||||
Имена полей ответа нормативны и MUST остаться прежними: контракт HTTP API
|
||||
объявлен проектом необратимым, и переименование поля ломает внешнюю программу
|
||||
молча. Меняются значения поля рубежа, а не его имя.
|
||||
|
||||
Отказ по отсутствию сессии наступает **раньше** чтения тела: запись, за которую
|
||||
не заплатит узнанный отправитель, не должна попасть даже в память.
|
||||
|
||||
Приём не судит о годности записи сам: расширение он берёт из имени файла, а
|
||||
пригодность содержимого узнаёт у источника метаданных.
|
||||
|
||||
Куда именно ложится принятая запись, приёму не принадлежит: раскладку выбирает
|
||||
хранилище, и нормирует её capability `storage`.
|
||||
|
||||
Владельцем принятой записи приём SHALL назначать предъявителя сессии. Обязательность
|
||||
владельца при этом MUST держаться и схемой хранилища: колонка владельца пустого
|
||||
значения не принимает вовсе, и норму эту держит capability `storage`. Проверка в
|
||||
приёме от этого не лишняя — она отвечает отправителю понятным отказом до того, как
|
||||
запись попадёт в память, а схема отвечала бы отказом сохранения после укладки
|
||||
файла.
|
||||
|
||||
Предъявитель, чья сессия не даёт учётной записи пользователя, MUST получать
|
||||
отказ `403` и MUST получать его **до чтения тела** — там же, где стоит отказ по
|
||||
отсутствию сессии. Сессия владельца панели — именно такой случай: узнан он всё
|
||||
же узнан, а записи в коллекции пользователей у него нет, и владельцем записи он
|
||||
стать не может.
|
||||
|
||||
Код здесь другой, чем у запроса без сессии, и это не оплошность: `401` значит
|
||||
«предъяви себя», а предъявитель себя предъявил. Утечки по разнице кодов нет —
|
||||
оба ответа говорят о самом спрашивающем, а не о том, какие записи заведены.
|
||||
|
||||
Отказ **после** укладки записи потребовал бы убрать уже сохранённый файл, а
|
||||
уборки файлов сервис не умеет вовсе: норма, обязывающая к недостижимому, не
|
||||
пишется.
|
||||
|
||||
#### Scenario: Запись принята
|
||||
|
||||
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
|
||||
- **AND** отправитель предъявил сессию
|
||||
- **WHEN** программа шлёт `POST /api/audio` с полем `audio`
|
||||
- **THEN** ответ имеет код `201`, а в теле лежат непустой `job_id` и `status`
|
||||
со значением `uploaded`
|
||||
- **AND** содержимое записи целиком лежит в хранилище одним файлом
|
||||
- **AND** владельцем заведённой аудиозаписи стоит предъявитель сессии
|
||||
|
||||
#### Scenario: Сессия не даёт учётной записи пользователя
|
||||
|
||||
- **GIVEN** предъявлена сессия владельца панели
|
||||
- **WHEN** он шлёт `POST /api/audio` с полем `audio`
|
||||
- **THEN** ответ имеет код `403`
|
||||
- **AND** ни файла, ни аудиозаписи не заводится
|
||||
|
||||
#### Scenario: Сессии нет
|
||||
|
||||
- **WHEN** программа шлёт `POST /api/audio` с полем `audio` без сессии
|
||||
- **THEN** ответ имеет код `401`
|
||||
- **AND** ни файла, ни аудиозаписи не заводится
|
||||
- **AND** тело ответа не несёт данных записи
|
||||
|
||||
#### Scenario: Поля с записью нет
|
||||
|
||||
- **GIVEN** отправитель предъявил сессию
|
||||
- **WHEN** программа шлёт `POST /api/audio` без поля `audio`
|
||||
- **THEN** ответ имеет код `400` и сообщение об отсутствии записи
|
||||
- **AND** ни файла, ни аудиозаписи не заводится
|
||||
|
||||
#### Scenario: Размеру записи приём не судья
|
||||
|
||||
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
|
||||
- **AND** отправитель предъявил сессию
|
||||
- **WHEN** программа шлёт запись нулевой длины
|
||||
- **THEN** ответ имеет код `201`: собственного порога по размеру у приёма нет
|
||||
|
||||
### Requirement: Опрос готовности задачи
|
||||
|
||||
Сервис SHALL отдавать рубеж аудиозаписи по запросу `GET /api/status/:id`
|
||||
**только её владельцу**. Запрос без сессии MUST получать код `401`, и тело
|
||||
такого ответа MUST не нести ни рубежа записи, ни текста расшифровки. Ответ
|
||||
владельцу MUST нести идентификатор полем `job_id`, рубеж полем `status` и время
|
||||
заведения полем `created_at`, а текст расшифровки полем `transcription_text`, и
|
||||
это поле MUST отсутствовать в ответе, пока текста нет: пустая строка на месте
|
||||
отсутствующего текста читается как «расшифровка пуста».
|
||||
|
||||
Видов текста у записи больше одного, поэтому ответ MUST называть вид, который
|
||||
отдаёт: в поле `transcription_text` уходит **сырая расшифровка**, и только она.
|
||||
Вычитанный текст этим полем MUST не подменяться — иначе значение поля менялось бы
|
||||
у одной и той же записи от того, успел ли отработать необязательный шаг, а
|
||||
контракт объявлен необратимым. Отдача «последнего записанного» текста MUST не
|
||||
применяться: она делает ответ функцией порядка записи, а не состояния записи.
|
||||
|
||||
Перечень значений поля `status` MUST совпадать с перечнем рубежей конвейера:
|
||||
`uploaded`, `normalized`, `submitted`, `transcribed`, `done`. Прежних значений
|
||||
`created`, `converted`, `transcribe`, `failed` и `dead` в ответе MUST не быть.
|
||||
Это объявленная ломка публичного контракта: рубеж называет достигнутое, а отказ
|
||||
перестал быть состоянием.
|
||||
|
||||
Остановленная запись MUST отдавать рубеж, на котором она остановлена, и MUST
|
||||
нести признак остановки отдельным полем `halted` со значением истины. Машинный
|
||||
текст отказа MUST в ответ не попадать: он принадлежит журналу владельца сервиса,
|
||||
а не отправителю. Этот адрес — **единственное** место, где отправитель узнаёт о
|
||||
неудаче: доставки ответа отправителю у сервиса больше нет, и признак остановки
|
||||
здесь несёт всю обязанность целиком.
|
||||
|
||||
Отказ без сессии MUST не зависеть от того, есть такая запись или нет: иначе по
|
||||
кодам ответа перебирается список заведённых записей.
|
||||
|
||||
Запись, принадлежащая другому, MUST отвечать тем же, чем отвечает неизвестный
|
||||
идентификатор, — кодом `404` и тем же телом.
|
||||
|
||||
#### Scenario: Запись найдена
|
||||
|
||||
- **GIVEN** отправитель предъявил сессию
|
||||
- **WHEN** он спрашивает рубеж своей записи
|
||||
- **THEN** ответ имеет код `200` и несёт `job_id`, `status` и `created_at`
|
||||
- **AND** значение `status` принадлежит перечню рубежей конвейера
|
||||
|
||||
#### Scenario: Запись остановлена
|
||||
|
||||
- **GIVEN** запись остановлена признаком на рубеже приведения
|
||||
- **WHEN** владелец спрашивает её рубеж
|
||||
- **THEN** поле `status` несёт рубеж приведения
|
||||
- **AND** поле `halted` несёт истину
|
||||
- **AND** машинного текста отказа в ответе нет
|
||||
|
||||
#### Scenario: Сессии нет
|
||||
|
||||
- **WHEN** программа спрашивает рубеж заведённой записи без сессии
|
||||
- **THEN** ответ имеет код `401`
|
||||
- **AND** тело ответа не несёт ни рубежа записи, ни текста расшифровки
|
||||
|
||||
#### Scenario: Без сессии неизвестная запись неотличима от заведённой
|
||||
|
||||
- **WHEN** программа без сессии спрашивает рубеж заведённой записи, а затем
|
||||
рубеж по неизвестному идентификатору
|
||||
- **THEN** оба ответа имеют код `401`
|
||||
|
||||
#### Scenario: Чужая запись неотличима от неизвестной
|
||||
|
||||
- **GIVEN** запись заведена одним вошедшим
|
||||
- **WHEN** её рубеж спрашивает другой вошедший
|
||||
- **THEN** ответ имеет код `404` и то же тело, что и ответ по неизвестному
|
||||
идентификатору
|
||||
- **AND** тело ответа не несёт ни рубежа записи, ни текста расшифровки
|
||||
|
||||
#### Scenario: Расшифровки ещё нет
|
||||
|
||||
- **GIVEN** отправитель предъявил сессию
|
||||
- **WHEN** он спрашивает рубеж своей записи, которая ещё не дошла до текста
|
||||
- **THEN** поля `transcription_text` в ответе нет вовсе
|
||||
|
||||
#### Scenario: Записи с таким идентификатором нет
|
||||
|
||||
- **GIVEN** отправитель предъявил сессию
|
||||
- **WHEN** программа спрашивает рубеж по неизвестному идентификатору
|
||||
- **THEN** ответ имеет код `404` и сообщение о ненайденной записи
|
||||
|
||||
### Requirement: Имя файла, данное отправителем, не попадает в журнал
|
||||
|
||||
Приём SHALL не писать имя файла, данное отправителем, ни в одну свою журнальную
|
||||
запись — ни на успешном пути, ни на пути отказа, где имя могло бы приехать
|
||||
текстом ошибки. Имя приходит извне вместе с записью и принадлежит содержимому
|
||||
личной переписки наравне с текстом расшифровки; журнал уезжает в собранные логи,
|
||||
откуда строку не убрать.
|
||||
|
||||
Расширение, взятое из этого имени, в журнале остаётся собственным полем: по нему
|
||||
прослеживается путь записи. Что именно попадает в журнал ради прослеживаемости,
|
||||
нормирует требование ниже; наружу расширение выходит только приведённым к
|
||||
известному виду — этому отдано отдельное требование.
|
||||
|
||||
Оговорка про второй вход из требования ушла вместе с ним: имя, данное
|
||||
отправителем, доходит до сервиса единственным путём — приёмом по HTTP, — и
|
||||
сценарии судят именно его.
|
||||
|
||||
#### Scenario: Имя записи не видно в журнале принятой записи
|
||||
|
||||
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
|
||||
- **WHEN** программа шлёт `POST /api/audio` с записью, чья основа имени несёт
|
||||
опознаваемую строку при обычном расширении `.mp3`
|
||||
- **THEN** ни одна журнальная запись приёма этой строки не содержит
|
||||
- **AND** расширение `.mp3` в журнале допустимо
|
||||
|
||||
#### Scenario: Имя записи не видно в журнале при отказе приёма
|
||||
|
||||
- **GIVEN** источник метаданных не может прочитать запись
|
||||
- **WHEN** программа шлёт `POST /api/audio` с записью, чья основа имени несёт
|
||||
опознаваемую строку
|
||||
- **THEN** ни одна журнальная запись приёма, включая запись об ошибке, этой
|
||||
строки не содержит
|
||||
|
||||
### Requirement: Поднятые входы видны наблюдателю
|
||||
|
||||
Сервис SHALL отдавать признак поднятости по каждому входу приёма отдельной
|
||||
метрикой и MUST выставлять метку только тому входу, который у сервиса есть.
|
||||
Метки убранного входа в метриках MUST не быть вовсе: признак со значением нуля
|
||||
читался бы как «вход есть, но не поднялся», то есть как поломка, а вечная
|
||||
единица рядом с ним — как исправность того, чего нет.
|
||||
|
||||
Проверяемое здесь одно — **набор меток**, и это честнее прежнего. Вход остался
|
||||
один, страница метрик отдаётся тем же сервером, что и приём, и значение нуля у
|
||||
единственной метки недостижимо: чтобы прочитать признак, надо дотянуться до
|
||||
входа, о котором он сообщает. Прежнее обоснование — «иначе потерянный вход не
|
||||
виден ничем» — было верно, пока входов было два; сегодня неподнятый вход виден
|
||||
неудачей чтения самих метрик.
|
||||
|
||||
Различать поднятый и неподнятый вход признак MUST снова, как только входов у
|
||||
сервиса станет больше одного.
|
||||
|
||||
#### Scenario: В метриках только оставшийся вход
|
||||
|
||||
- **GIVEN** сервис поднялся
|
||||
- **WHEN** наблюдатель читает метрики
|
||||
- **THEN** признак поднятости несёт метку входа HTTP со значением единицы
|
||||
- **AND** метки убранного входа Telegram в метриках нет вовсе
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
### Requirement: Признак включения решает, поднимается ли вход Telegram
|
||||
|
||||
**Reason**: Вход Telegram убран из сервиса целиком, и решать о его подъёме стало
|
||||
нечего. Настройки входа — признак включения, ключ доступа и срок ожидания
|
||||
обновлений — уходят из конфигурации вместе с ним.
|
||||
|
||||
**Migration**: Секцию `[telegram]` и ключ `server.users_while_list` — имя в коде
|
||||
именно такое, с опечаткой, и в боевом файле искать надо его — из файла настроек
|
||||
убрать руками. Оставленные ключи сервис пропускает молча — незнакомые
|
||||
ключи разбор настроек не судит, — и потому файл, забытый как есть, поднимет
|
||||
сервис без бота и без единого слова о том, что секция больше ничего не значит.
|
||||
Записи с источником Telegram, если они в базе есть, остаются нетронутыми и
|
||||
достаются своему владельцу; ответ в чат по ним не уходит. Возврат входа заводится
|
||||
новым изменением вместе со связью чата и учётной записи.
|
||||
@@ -0,0 +1,258 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Конвейер ответа отправителю не шлёт
|
||||
|
||||
Шаг конвейера SHALL доводить запись до достигнутого рубежа и MUST не обращаться
|
||||
к отправителю вовсе — ни с готовым текстом, ни с сообщением о неудаче. Исход
|
||||
своей записи отправитель узнаёт опросом готовности и в панели владельца; адрес
|
||||
опроса и содержимое ответа нормирует capability `intake`.
|
||||
|
||||
Требование заведено взамен доставки в чат, убранной вместе с входом Telegram.
|
||||
Без него молчание конвейера читалось бы как недоделка: прежде ответ уходил, и
|
||||
всякий, кто помнит это, ищет в шаге отправку, а её отсутствие принимает за
|
||||
потерянную ветку.
|
||||
|
||||
Инвариант проекта «Принятая запись не теряется молча» держится теперь опросом
|
||||
готовности — там остановка видна признаком — и журналом владельца, где у неё
|
||||
стоит причина. Обязанность при этом сменила направление: прежде об отказе
|
||||
сообщали, теперь отказ доступен спросившему. Отправитель, который не
|
||||
спрашивает, об остановке не узнаёт.
|
||||
|
||||
Записи, которой этот канал недоступен, не бывает: у каждой записи есть владелец,
|
||||
и опрос отдаёт ему её исход. Держится это обязательностью владельца в схеме
|
||||
хранилища — норму держит capability `storage`.
|
||||
|
||||
#### Scenario: Готовый текст отправителю не уходит
|
||||
|
||||
- **GIVEN** запись дошла до конечного рубежа
|
||||
- **WHEN** шаг конвейера её завершает
|
||||
- **THEN** ни одного обращения наружу с текстом расшифровки не уходит
|
||||
- **AND** текст достаётся опросом готовности
|
||||
|
||||
#### Scenario: Остановка видна опросом, а не сообщением
|
||||
|
||||
- **GIVEN** запись остановлена по исчерпании отказов
|
||||
- **WHEN** владелец записи спрашивает её рубеж
|
||||
- **THEN** ответ несёт достигнутый рубеж и признак остановки
|
||||
- **AND** в журнале владельца сервиса есть запись об остановке с причиной
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Захват задачи неделим
|
||||
|
||||
Захват записи воркером SHALL быть одним неделимым шагом хранилища: выбор
|
||||
подходящей записи и пометка её захваченной MUST происходить вместе.
|
||||
|
||||
Захват MUST возвращать **идентификатор записи и признак этого захвата**, а не
|
||||
перечень её колонок. Колонки записи шаг читает сам, обычным чтением. Иначе
|
||||
всякая новая колонка аудиозаписи попадала бы под инвариант проекта о колонках
|
||||
очереди, и забытая в захвате колонка приезжала бы нулевой, а первое же
|
||||
сохранение писало бы этот ноль поверх сохранённого значения.
|
||||
|
||||
**Признак захвата MUST быть значением, уникальным для каждого захвата**, а не
|
||||
признаком занятости. Условие записи результата сверяет именно это значение:
|
||||
захват, перевыданный другому — по протуханию срока или после того, как человек
|
||||
снял признак остановки в панели, — обязан обращать запись первого в отказ.
|
||||
Условие, проверяющее лишь непустоту признака или срок, пропустило бы обоих, и
|
||||
два шага записали бы в одну запись по очереди, испортив её результат.
|
||||
|
||||
Одна и та же запись MUST доставаться ровно одному захватившему. Двум вызывающим,
|
||||
пришедшим за работой одновременно, запись MUST достаться одному, а второй MUST
|
||||
получить признак «работы сейчас нет».
|
||||
|
||||
Срок протухания захвата MUST ехать с рубежом записи, а не с воркером: воркер не
|
||||
привязан к шагу и не знает заранее, что вытянет. Срок MUST записываться числом
|
||||
при самом захвате.
|
||||
|
||||
Порядок выборки MUST быть определён однозначно: сравнения по неуникальному
|
||||
значению для этого мало, и к нему MUST добавляться ключ записи. Иначе порядок
|
||||
обработки невоспроизводим, а проверка, опирающаяся на «следующую» запись, зелена
|
||||
через раз.
|
||||
|
||||
Требование стоит на инварианте проекта «Принятая запись не теряется молча»:
|
||||
захват, разделённый на два шага, отдаёт одну запись двум воркерам, и работа
|
||||
одного из них теряется без следа.
|
||||
|
||||
Признак «работы нет» этим требованием не переопределяется — его нормирует
|
||||
требование «Пустой прогон воркера — не отказ».
|
||||
|
||||
#### Scenario: За работой пришли трое разом
|
||||
|
||||
- **GIVEN** к работе пригодна ровно одна запись
|
||||
- **WHEN** три захвата идут одновременно
|
||||
- **THEN** запись получает ровно один из них
|
||||
- **AND** двое остальных получают признак «работы сейчас нет»
|
||||
|
||||
#### Scenario: Захваченная запись не выдаётся второй раз
|
||||
|
||||
- **GIVEN** запись захвачена и срок захвата не истёк
|
||||
- **WHEN** приходит следующий захват
|
||||
- **THEN** эта запись ему не выдаётся
|
||||
|
||||
#### Scenario: Захват отдаёт идентификатор и свой признак
|
||||
|
||||
- **GIVEN** к работе пригодна запись
|
||||
- **WHEN** воркер её захватывает
|
||||
- **THEN** захват возвращает идентификатор записи и признак этого захвата
|
||||
- **AND** колонки записи шаг читает отдельным чтением
|
||||
|
||||
#### Scenario: Признак перевыданного захвата отличается от прежнего
|
||||
|
||||
- **GIVEN** запись захвачена, и признак первого захвата известен
|
||||
- **WHEN** человек снимает признак остановки, и запись захватывает другой воркер
|
||||
- **THEN** признак нового захвата отличается от признака первого
|
||||
|
||||
### Requirement: Результат пишет только держатель захвата
|
||||
|
||||
Шаг конвейера SHALL записывать свой результат только тогда, когда захват записи
|
||||
всё ещё принадлежит ему. Запись MUST быть условна по **признаку этого захвата** —
|
||||
значению, уникальному для каждого захвата, — а не по занятости записи вообще.
|
||||
Шаг, чей захват за время работы достался другому, MUST завершиться без записи
|
||||
результата.
|
||||
|
||||
Требование закрывает то, чего неделимость захвата не закрывает: захват протухает
|
||||
не только у мёртвого воркера, но и у живого — шаг, идущий дольше своего срока,
|
||||
теряет запись, продолжая работать. Снять захват может и человек, вернувший
|
||||
остановленную запись в работу. Без условия по уникальному признаку два воркера
|
||||
пишут в одну запись по очереди, а счётчик отказов сбрасывает тот, кто уже не
|
||||
владелец.
|
||||
|
||||
Довод про два ответа отправителю из требования ушёл вместе с доставкой: обращений
|
||||
наружу шаг не делает. Требование от этого не ослабло — порча записи двумя
|
||||
пишущими остаётся его предметом целиком.
|
||||
|
||||
Шаг MUST записывать только те поля, которыми распоряжается сам. Запись он держит
|
||||
снимком с момента захвата и до записи — это часы, — и безусловная запись снимка
|
||||
стёрла бы всё, что владелец правил в панели за это время: молча, без строки в
|
||||
журнале и без отказа в панели. Владелец увидел бы успешное сохранение и был бы
|
||||
уверен, что правка на месте. Владелец записи, заголовок, краткое описание и темы
|
||||
конвейер MUST не трогать.
|
||||
|
||||
#### Scenario: Правка владельца пережила сохранение шага
|
||||
|
||||
- **GIVEN** шаг держит захваченную запись
|
||||
- **AND** владелец за это время изменил в панели поле, которого шаг не касается
|
||||
- **WHEN** шаг записывает свой результат
|
||||
- **THEN** результат шага записан
|
||||
- **AND** правка владельца на месте
|
||||
|
||||
#### Scenario: Захват ушёл под работающим шагом
|
||||
|
||||
- **GIVEN** шаг работает над захваченной записью
|
||||
- **AND** за это время та же запись досталась другому захвату
|
||||
- **WHEN** первый шаг доходит до записи результата
|
||||
- **THEN** результат не записывается
|
||||
|
||||
#### Scenario: Человек снял остановку под работающим шагом
|
||||
|
||||
- **GIVEN** шаг работает над захваченной записью
|
||||
- **AND** человек за это время снял с неё признак остановки, освободив захват
|
||||
- **AND** запись досталась другому воркеру
|
||||
- **WHEN** первый шаг доходит до записи результата
|
||||
- **THEN** результат не записывается
|
||||
|
||||
### Requirement: Число отказов ограничивает повторы шага
|
||||
|
||||
У аудиозаписи SHALL быть число отказов. Оно MUST расти при каждом захвате и MUST
|
||||
возвращаться к нулю, когда шаг завершился без отказа либо отложил работу. Рост
|
||||
при захвате, а не при отказе, засчитывает попытку и записи, брошенной на
|
||||
середине: шаг, уносящий с собой процесс, до объявления отказа не доходит
|
||||
никогда.
|
||||
|
||||
**Остановка сервиса отказом не считается.** Шаг, прерванный отменой по
|
||||
собственной остановке сервиса, MUST возвращать число отказов назад и MUST не
|
||||
выносить записи приговора: запись не виновата в том, что нас перезапустили, и
|
||||
несколько выкладок подряд иначе останавливают здоровую многочасовую запись с
|
||||
приговором «отказы исчерпаны». Всякая другая причина, по которой шаг не дошёл до
|
||||
объявления исхода, отказ тратит.
|
||||
|
||||
Запись, захваченная с числом отказов сверх заданного предела, MUST
|
||||
останавливаться признаком тем, кто её захватил, и MUST не отдаваться шагу в
|
||||
работу. Остановка эта видна отправителю опросом готовности наравне с прочими —
|
||||
норму держит capability `intake`.
|
||||
|
||||
Этот сторож MUST отвечать только за повторы внутри шага. Время, проведённое
|
||||
записью в рубеже, MUST мериться отдельным сторожем: одно число не справляется ни
|
||||
с одной из двух обязанностей — опрос, вернувший «ещё в работе», обнуляет его, и
|
||||
зависшая чужая операция опрашивается вечно, а не обнулял бы — убивал бы здоровую
|
||||
запись.
|
||||
|
||||
#### Scenario: Запись отказывает на каждой попытке
|
||||
|
||||
- **GIVEN** шаг конвейера отказывает на каждой попытке
|
||||
- **WHEN** запись проходит заданное число отказов
|
||||
- **THEN** у неё появляется признак остановки
|
||||
- **AND** следующий захват её не выдаёт
|
||||
- **AND** опрос готовности отдаёт владельцу записи признак остановки
|
||||
|
||||
#### Scenario: Шаг уносит процесс, не объявив отказа
|
||||
|
||||
- **GIVEN** шаг конвейера обрывается вместе с процессом на каждой попытке
|
||||
- **WHEN** запись захватывается снова заданное число раз
|
||||
- **THEN** у неё появляется признак остановки
|
||||
|
||||
#### Scenario: Остановка сервиса отказа не тратит
|
||||
|
||||
- **GIVEN** шаг работает над записью
|
||||
- **WHEN** сервис останавливают, и шаг прерывается отменой
|
||||
- **THEN** число отказов записи прежнее
|
||||
- **AND** признака остановки у записи не появляется
|
||||
|
||||
#### Scenario: Прошедшая запись отказов не копит
|
||||
|
||||
- **GIVEN** запись прошла подряд несколько рубежей без единого отказа
|
||||
- **WHEN** смотрят её число отказов
|
||||
- **THEN** оно не приблизилось к пределу
|
||||
|
||||
### Requirement: Выборка воркера владельцем не сужается
|
||||
|
||||
Воркер SHALL брать записи всех владельцев подряд и MUST не учитывать владельца
|
||||
при выборе очередной записи.
|
||||
|
||||
Владелец решает, кому запись показывать, а не кому её считать. Сужение выборки
|
||||
владельцем поставило бы записи одних людей в зависимость от того, кто первым
|
||||
завёл учётную запись.
|
||||
|
||||
Оговорка про записи без владельца из требования ушла: заводить их стало нечем —
|
||||
колонка владельца пустого значения не принимает, и норму держит capability
|
||||
`storage`.
|
||||
|
||||
Владелец записи MUST переживать работу конвейера: шаг, сохраняющий свой
|
||||
результат, владельца не трогает и не затирает.
|
||||
|
||||
#### Scenario: Записи двух владельцев проходят одним воркером
|
||||
|
||||
- **GIVEN** заведены записи двух разных владельцев на одном рубеже
|
||||
- **WHEN** воркер забирает работу
|
||||
- **THEN** ему достаются обе, в порядке заведения
|
||||
|
||||
#### Scenario: Шаг конвейера владельца не затирает
|
||||
|
||||
- **GIVEN** запись с владельцем прошла шаг конвейера
|
||||
- **WHEN** шаг сохраняет свой результат
|
||||
- **THEN** владелец записи остаётся прежним
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
### Requirement: Недоставленный ответ не роняет шаг
|
||||
|
||||
**Reason**: Доставка ответа отправителю убрана вместе с входом Telegram, и
|
||||
недоставке взяться неоткуда: обращения наружу шаг больше не делает. Обе прежние
|
||||
причины недоставки — неподнятый вход отправителя и неназванный адресат записи —
|
||||
описывали именно этот вход.
|
||||
|
||||
**Migration**: Счётчик недоставленных ответов и записи журнала о недоставке
|
||||
уходят вместе с требованием; наблюдателю, построившему на них отбор, ждать от
|
||||
них значений больше нечего. Исход записи виден опросом готовности и журналом
|
||||
событий записи.
|
||||
|
||||
### Requirement: Всякая остановка сообщает отправителю
|
||||
|
||||
**Reason**: Обязанность сообщить требовала адресата, а адресатом был чат
|
||||
Telegram. С убранным входом сообщать стало нечем и некуда, и обязанность
|
||||
переходит к опросу готовности — её держит требование «Конвейер ответа
|
||||
отправителю не шлёт» вместе с capability `intake`.
|
||||
|
||||
**Migration**: Отправитель узнаёт об остановке признаком в ответе опроса
|
||||
готовности. Владелец сервиса видит остановку записью журнала и полем причины у
|
||||
самой записи — как и прежде.
|
||||
@@ -0,0 +1,181 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Пустой результат не кладётся поверх сохранённого
|
||||
|
||||
Хранилище SHALL не заменять сохранённое содержимое приложения записи — текст и
|
||||
структуру реплик — пустым. Замена пустым MUST оставлять прежнее значение и
|
||||
считаться сделанной работой, а не отказом.
|
||||
|
||||
Требование стоит на повторном опросе одной и той же операции распознавания.
|
||||
Повтор — обычное дело: держатель захвата умер, сохранение рубежа отказало,
|
||||
человек снял признак остановки в панели. Провайдер при этом вправе ответить
|
||||
пустым потоком, отказом это не считается, и безусловная замена стирала бы
|
||||
расшифровку живого человека — без следа и без возврата, потому что сервис
|
||||
объявлен архивом и удаления по требованию не знает.
|
||||
|
||||
Та же защита MUST стоять у сырого ответа провайдера: разное правило у двух
|
||||
хранителей одного результата читается как недосмотр, и один из них молча теряет
|
||||
то, ради чего второй заведён.
|
||||
|
||||
Норма записана со стороны **хранилища**, а не шага: шагов, кладущих текст,
|
||||
больше одного, и правило, записанное у одного из них, у остальных читалось бы
|
||||
как снятое.
|
||||
|
||||
#### Scenario: Пустой второй ответ не стирает расшифровку
|
||||
|
||||
- **GIVEN** расшифровка записи сохранена
|
||||
- **WHEN** ту же операцию опрашивают снова, и провайдер отвечает пустым
|
||||
- **THEN** сохранённая расшифровка остаётся прежней
|
||||
- **AND** шаг завершается без отказа
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Сервис поднимается на чистом каталоге данных
|
||||
|
||||
Сервис SHALL приводить хранилище в рабочий вид сам: на пустом каталоге данных он
|
||||
MUST завести свою схему и принимать записи своим входом — приёмом по HTTP — без
|
||||
единого ручного шага до первого запуска.
|
||||
|
||||
Прежние данные не переносятся. Каталог, оставшийся от прежней раскладки, MUST не
|
||||
читаться и не считаться источником: сервис начинает с чистого листа, и это
|
||||
решение задачи, а не следствие отказа.
|
||||
|
||||
Схема MUST заводиться версионированными шагами, а применённый шаг MUST не
|
||||
переписываться — только новым шагом. Иначе повторный запуск на уже заведённом
|
||||
каталоге разошёлся бы с первым молча.
|
||||
|
||||
Каталог данных у сервиса MUST быть один: база и файлы записей лежат под ним
|
||||
вместе, и второго пути к ним не заводится.
|
||||
|
||||
#### Scenario: Первый запуск на пустом каталоге
|
||||
|
||||
- **GIVEN** каталог данных пуст
|
||||
- **WHEN** сервис запускается
|
||||
- **THEN** он заводит своё хранилище и продолжает работу
|
||||
- **AND** принятая следом запись доходит до состояния `done`
|
||||
|
||||
#### Scenario: Повторный запуск на заведённом каталоге
|
||||
|
||||
- **GIVEN** сервис уже запускался на этом каталоге и завёл хранилище
|
||||
- **WHEN** он запускается снова
|
||||
- **THEN** он не заводит схему второй раз и не теряет прежние записи
|
||||
|
||||
### Requirement: Пароль владельца от панели не лежит в конфигурации
|
||||
|
||||
Сервис SHALL не заводить в конфигурации ключа под пароль владельца от панели.
|
||||
Пароль MUST задаваться самим владельцем, а хранилище MUST держать только его
|
||||
отпечаток.
|
||||
|
||||
Требование стоит на инварианте проекта «Секрет не покидает конфиг» с другой
|
||||
стороны: секрет, которого в конфигурации нет, не утекает вместе с ней и не
|
||||
уезжает в выкладку третьим путём. Пароль от панели открывает все записи и все
|
||||
файлы разом — это самое чувствительное, что есть у сервиса.
|
||||
|
||||
Приглашение завести владельца сервис MUST печатать только пока владельца нет, и
|
||||
оно MUST истекать по времени. Приглашение равносильно паролю от панели, а
|
||||
печатается оно в журнал контейнера, откуда строку не убрать: бессрочное отдало бы
|
||||
панель всякому читателю логов навсегда.
|
||||
|
||||
Пока владелец пароля не задал, сервис MUST принимать записи: панель без владельца
|
||||
приёму не мешает.
|
||||
|
||||
#### Scenario: Владелец пароля ещё не задал
|
||||
|
||||
- **GIVEN** каталог данных пуст и владелец панели не заведён
|
||||
- **WHEN** сервис запускается
|
||||
- **THEN** он принимает записи
|
||||
- **AND** ни один ключ конфигурации не несёт пароля от панели
|
||||
|
||||
#### Scenario: Владелец заведён, приглашение больше не печатается
|
||||
|
||||
- **GIVEN** владелец панели заведён
|
||||
- **WHEN** сервис запускается снова
|
||||
- **THEN** приглашения завести владельца в журнале нет
|
||||
|
||||
### Requirement: Владелец задачи лежит связью с учётной записью
|
||||
|
||||
Хранилище SHALL держать владельца аудиозаписи отдельной колонкой — связью с
|
||||
учётной записью, — и эта колонка MUST не иметь умолчания: запись, чей владелец
|
||||
не назван, не достаётся никому по недосмотру схемы.
|
||||
|
||||
Колонка MUST не допускать пустого значения. Прежде допускала, и цену платили за
|
||||
записи, принятые ботом: связи чата с учётной записью сервис не вёл. С убранным
|
||||
входом заводить ничью запись стало некому, и обязательность переезжает из одного
|
||||
лишь приёма в схему — туда, где её держит хранилище, а не договорённость. Разница
|
||||
не косметическая: пока обязательность жила в приёме, ничью запись заводили руками
|
||||
в панели, и она уходила в конвейер, стоила денег на распознавание и не доставалась
|
||||
потом никому.
|
||||
|
||||
Владелец MUST не назначаться и не меняться конвейером.
|
||||
|
||||
#### Scenario: Колонка появляется на пустой базе
|
||||
|
||||
- **WHEN** сервис поднимается на чистом каталоге данных
|
||||
- **THEN** у аудиозаписи есть колонка владельца
|
||||
- **AND** умолчания у неё нет
|
||||
- **AND** пустого значения она не принимает
|
||||
|
||||
#### Scenario: Запись без владельца не сохраняется
|
||||
|
||||
- **GIVEN** сервис поднят
|
||||
- **WHEN** аудиозапись пытаются сохранить с пустым владельцем — приёмом,
|
||||
конвейером или руками в панели
|
||||
- **THEN** хранилище её не сохраняет
|
||||
|
||||
#### Scenario: Конвейер владельца не назначает
|
||||
|
||||
- **GIVEN** запись с владельцем прошла шаг конвейера
|
||||
- **WHEN** смотрят её владельца
|
||||
- **THEN** он прежний
|
||||
|
||||
### Requirement: Файл записи сужается владельцем наравне с задачей
|
||||
|
||||
Хранилище SHALL держать владельца и у файла записи — той же связью с учётной
|
||||
записью, — и правило просмотра файлов MUST пускать к файлу только его владельца.
|
||||
|
||||
Владелец файла MUST назначаться при приёме, из предъявленной сессии, а колонка
|
||||
файла MUST не допускать пустого значения наравне с колонкой записи. Прежде пустое
|
||||
значение оставалось у файлов, заведённых конвейером для записи без владельца;
|
||||
таких записей больше не заводится, и разное правило у записи и у её файла
|
||||
читалось бы как недосмотр.
|
||||
|
||||
Файл, заведённый шагом конвейера, — приведённую копию заводит именно он —
|
||||
MUST получать владельца своей записи. Иного источника владельца у файла нет, и
|
||||
шаг, оставивший его пустым, упрётся в отказ сохранения: запись накопит отказы и
|
||||
остановится признаком на первом же приведении.
|
||||
|
||||
Ссылки на файлы у записи две — на принятую копию и на приведённую, — и обе живут
|
||||
до конца, но владелец файла MUST по-прежнему лежать своей колонкой, а не
|
||||
выводиться через запись: файл переживает свою запись, и заведённый шагом до
|
||||
сохранения записи он остаётся с владельцем и без ссылки.
|
||||
|
||||
Отказ наступает **на переходе по ссылке**, а не на выдаче токена файла: токен
|
||||
хранилище выдаёт на предъявителя, а не на файл, и о файле при выдаче не
|
||||
спрашивает вовсе. Требовать отказа при выдаче значит требовать механизма,
|
||||
которого нет, — а проверка, написанная под такое требование, зеленела бы, не
|
||||
касаясь пути, по которому аудио и уходит.
|
||||
|
||||
#### Scenario: Чужой файл не отдаётся
|
||||
|
||||
- **GIVEN** запись принята одним вошедшим
|
||||
- **WHEN** другой вошедший идёт по ссылке на файл этой записи со своим токеном
|
||||
- **THEN** содержимого он не получает
|
||||
|
||||
#### Scenario: Свой файл отдаётся
|
||||
|
||||
- **GIVEN** человек принял запись
|
||||
- **WHEN** он идёт по ссылке на файл своей записи со своим токеном
|
||||
- **THEN** содержимое отдаётся
|
||||
|
||||
#### Scenario: Файл без владельца не сохраняется
|
||||
|
||||
- **GIVEN** сервис поднят
|
||||
- **WHEN** файл записи пытаются сохранить с пустым владельцем
|
||||
- **THEN** хранилище его не сохраняет
|
||||
|
||||
#### Scenario: Приведённая копия получает владельца записи
|
||||
|
||||
- **GIVEN** запись с владельцем дошла до приведения
|
||||
- **WHEN** шаг заводит приведённую копию файла
|
||||
- **THEN** владельцем копии стоит владелец записи
|
||||
- **AND** шаг завершается без отказа
|
||||
Reference in New Issue
Block a user