# 🐞 Не закрывать базу, пока живёт брошенный воркер

- **Тип:** fix
- **Категория:** Очередь — Остановка чинится раньше бюджета остановки: пока база закрывается под живым воркером, замерять мягкий бюджет нечем — процесс падает паникой, а захват стоит до восьми часов
- **Зачем:** По истечении жёсткого таймаута остановки процесс возвращается из run(), отложенный db.Close() обнуляет пулы, брошенный воркер разыменует nil и роняет процесс паникой; захват записи после этого стоит до восьми часов, и следа не остаётся.
- **Теги:** review-2026-08-23

Остановка сервиса не дожидается брошенных горутин. По истечении
`ForceShutdownTimeout` `run()` возвращается, отложенный `db.Close()` обнуляет
пулы соединений, а воркер, который не успел закончить шаг, разыменует `nil` и
роняет процесс паникой.

Замер прогона триажа: `Close()` вернулся за 2.8 мкс, пока другая горутина
держала пишущую транзакцию, и та зафиксировала её уже после закрытия.

Цена не в самой панике, а в следе: захваченная запись остаётся захваченной до
истечения срока захвата — до восьми часов, — и о причине в журнале нет ничего.
Инвариант «принятая запись не теряется молча» держится тем, что отказ виден
владельцу записи; здесь не виден никому.

Нашли проходы `review-code` (C2) и `review-ops` (O3) ревью change
`2026-08-23-storage-without-pocketbase`.

## Воспроизведение

1. Занять шаг конвейера работой дольше `[server] force_shutdown_timeout`.
2. Послать процессу `SIGTERM`.
3. По истечении жёсткого таймаута процесс возвращается из `run()` и закрывает
   базу; брошенный воркер обращается к закрытому пулу и роняет процесс паникой.
4. Запись остаётся с признаком захвата, а в журнале об этом ни строки.

## Затрагивает

- порядок остановки в `cmd/transcriber/main.go` — возврат из `run()` и
  отложенное закрытие базы;
- `internal/adapter/repo/sqlite/db.go` — `Close()` и жизненный цикл обоих пулов;
- цикл воркера `internal/controller/worker` — что делает шаг, у которого база
  закрылась под руками;
- ключи конфига `[server] shutdown_timeout` и `force_shutdown_timeout`;
- поведение при остановке в спеке `pipeline`.

## Критерии приёмки

- Жёсткая остановка не роняет процесс паникой. **Оракул:** тест — шаг спит
  дольше жёсткого таймаута, процесс останавливается, `recover` в тесте не
  срабатывает, код возврата нулевой.
- Обращение к закрытой базе отвечает отказом, а не паникой. **Оракул:** тест
  репозитория — вызов после `Close()` возвращает ошибку, и она отличима от
  «записи нет».
- О брошенной работе остаётся след. **Оракул:** тест с перехваченным журналом —
  жёсткая остановка при незакрытом шаге пишет строку с идентификатором записи и
  причиной.

## Рамки

Бюджет мягкой остановки этой задачей не замеряется — это задача
`context-cancel-in-pipeline`; здесь закрывается только путь после жёсткого
таймаута. Сроки захвата и рубежи не двигаются.
