# 🧹 Прервать шаг конвейера отменой контекста - **Тип:** chore - **Категория:** Очередь — Отмена контекста трогает те же шаги, что и таймауты: одна окрестность кода, два захода дороже одного. - **Зачем:** Половина сделана 2026-08-13 — контекст доходит до внешних вызовов, а прерванный шаг оставляет задачу на повтор и не тратит попытку, — но осталось то, ради чего задача заводилась: хранилище контекста не принимает ни одним методом, и бюджет мягкой остановки не замерен. Долг назван в `docs/architecture.md` — раздел про capability `pipeline`, где отмена контекста посреди шага перечислена среди неописанного. Ревью задачи `pocketbase-storage` 2026-08-12 его уточнило, и уточнение меняет способ починки: **передать `ctx` по всему коду недостаточно**. Хранилище не принимает контекста ни в одном из методов записи — ни в сохранении записи, ни в транзакции, ни в чтении по идентификатору; отменять там нечем без перехода части записи на сырые запросы, у которых контекст есть. Число снято на прогоне: под внешней эксклюзивной блокировкой файла базы захват задачи держится 9,53 секунды (таймаут занятости хранилища — 10 секунд) и возвращает отличимый отказ, а не «работы нет». **Сделано попутно 2026-08-13** (коммит `f494dcb`, работа о линтерах и контексте — задачей не бралась, поэтому карточка и осталась открытой): - контекст проложен от воркера и обоих входов до внешних вызовов: `ffmpeg` и `ffprobe` заводятся через `exec.CommandContext`, SpeechKit и Object Storage принимают его доводом, скачивание записи идёт запросом с контекстом; - прерванный шаг приговора не выносит: задача остаётся пригодной к повтору, попытку не тратит и отправителю о несуществующем сбое не сообщает. Отмена различается по `ctx.Err()`, а не по ошибке: убитый процесс отдаёт «signal: killed», и `errors.Is` его с отменой не свяжет; - воркер не считает остановку отказом и не пишет о ней владельцу; задача не забирается вовсе, если нас уже остановили; - принятие операции SpeechKit от отмены защищено своим пределом: потерянный идентификатор заставил бы повтор оплатить ту же запись второй раз; - проверки: `internal/service/shutdown_test.go`, `TestShutdownIsNotAFailure` в `internal/controller/worker/worker_test.go`. Из пяти критериев приёмки закрыты два — «оборванный шаг оставляет задачу пригодной к повтору» и «прерванный шаг не тратит попытку». Открыты три: бюджет остановки не замерен (хранилище контекста не принимает, и обрывать в нём нечего), три отмены подряд ни один тест не гоняет, спека `pipeline` отмену не описывает. ## Затрагивает - цикл воркера `internal/controller/worker`; - сигнатуры шагов конвейера в `internal/service`; - договор ядра с хранилищем `internal/contract` и его реализация в `internal/adapter/repo/pocketbase`; - таймауты мягкой и жёсткой остановки — конфиг, `[server] shutdown_timeout` и `force_shutdown_timeout`; - спека `pipeline`: сегодня раздел `Purpose` объявляет отмену контекста неописанной. ## Критерии приёмки - Шаг, идущий дольше мягкого таймаута, обрывается по сигналу остановки. Оракул — тест с шагом, спящим дольше таймаута: остановка укладывается в бюджет, а не доживает до жёсткой. - Оборванный шаг оставляет задачу пригодной к повтору. Оракул — тест `TestShutdownDuringConversionKeepsJobRetryable`: после остановки задача не захвачена, стоит в прежнем состоянии и выдаётся следующему прогону. - Прерванный шаг не тратит попытку. Оракул — тот же тест: счётчик попыток задачи после отмены равен тому, что был до захвата. - Три отмены подряд не уводят задачу в «мертва». Оракул — тест на трёх прерываниях подряд: счётчик попыток не растёт до предела, состояние задачи не меняется. - Спека `pipeline` описывает отмену, и раздел `Purpose` её больше не числит неописанной. Оракул — `openspec validate --strict`. ## Рамки Таймаут занятости хранилища этой задачей не трогаем: он свойство библиотеки, а не наше. Мягкая остановка в 5 секунд может оказаться меньше неотменяемого минимума — тогда число меняется, и это решение человека.