Устойчивость раскладки и переходов linking (MAJOR-4, MINOR-7)
Закрывает две связанные дыры «claim-then-side-effect» в раскладке хардлинками. MINOR-7: transition глотал ошибку записи состояния — на путях Apply и авто-раскладки выполнение продолжалось к хардлинкам при незакоммиченном claim перехода в linking, а финальный linking→done отклонялся графом (задача застревала со stale-планом). Выделен transitionErr, возвращающий ошибку; Apply и finishRecognition прерываются ДО linkPlan при провале claim. Обёртка transition (void) сохранена для fire-and-forget переходов — соседние функции воркера не тронуты. MAJOR-4: (A) провал CreateFileLinks после создания хардлинков больше не оставляет задачу в linking голым return — уводим в review (код persist), повтор Apply идемпотентен. (B) новый шаг pollOnce sweepLinking возвращает осиротевшие после краха linking-задачи в review (код interrupted) на тике и старте; любая linking под w.mu устарела по построению. Восстановлен инвариант «у каждого нетерминального состояния есть владелец». Граф переходов не тронут (ребро linking→review уже объявлено). Тесты: провал claim не создаёт хардлинков; провал учёта уводит в review; sweep осиротевшего linking. OpenSpec-change linking-transition-robustness (дельты file-layout, state-reconciliation). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
# Design
|
||||
|
||||
## Контекст
|
||||
|
||||
`linking` — короткое рабочее состояние между «решили раскладывать» и
|
||||
«разложили». Оно нетерминально и активно, но в отличие от `recognizing` его
|
||||
никто не листит на рестарте, а переход в него — обычный `SetDownloadState`, чей
|
||||
сбой раньше проглатывался. Обе дыры (MINOR-7, MAJOR-4) — про то, что `linking`
|
||||
не был устойчивым владельцем шага.
|
||||
|
||||
## Решение 1: `transition` возвращает ошибку — но только там, где она нужна
|
||||
|
||||
Параллельный поток правит соседние функции воркера (`Retry`/`checkTimeouts`/
|
||||
`torrentAge`), поэтому смена сигнатуры `transition` на всех ~20 вызовах
|
||||
(с добавлением `_ =` в fire-and-forget местах) создала бы лишние конфликты
|
||||
слияния и шум. Вместо этого:
|
||||
|
||||
- `transition(...)` остаётся `void` — обёртка, гасящая ошибку. Все существующие
|
||||
вызовы (reconcile, таймауты, команды ревью, финальные переходы `linkPlan`,
|
||||
sweep) не трогаются: за ними НЕТ побочного эффекта, зависящего от факта записи
|
||||
claim, — переход и есть конец шага.
|
||||
- `transitionErr(...)` — новая функция, тело прежнего `transition` + `return
|
||||
error`. Пинги/скан живут в ней (обёртка делегирует).
|
||||
|
||||
**Развилка:** менять сигнатуру `transition` глобально (честнее, но шумно и
|
||||
конфликтно) против точечного `transitionErr` (`mustTransition` из ревью). Выбран
|
||||
точечный вариант: минимальный след, локальные правки по функциям, ошибка
|
||||
возвращается ровно там, где за claim следует побочный эффект.
|
||||
|
||||
Использование: `Apply` и авто-раскладка в `finishRecognition` зовут
|
||||
`transitionErr(StateLinking)` и прерываются при ошибке ДО `linkPlan`. При провале
|
||||
claim `Apply` остаётся в `review`/`deferred`, а авто-путь — в `recognizing`
|
||||
(его повторит `recognizePending`); в обоих случаях владелец шага сохраняется.
|
||||
|
||||
## Решение 2: провал `CreateFileLinks` уводит в `review`, а не оставляет в `linking`
|
||||
|
||||
Хардлинки к этому моменту уже на диске — это учётный, а не безопасностный сбой
|
||||
(файлы разложены). Оставлять задачу в `linking` нельзя (осиротеет до sweep, а до
|
||||
того файлы висят без `file_link`). Уводим в `review` с кодом `persist`: повторный
|
||||
`Apply` идемпотентен — `layout.Apply` вернёт `StatusExists` на уже созданных
|
||||
ссылках, а `CreateFileLinks` допишет учёт.
|
||||
|
||||
**Почему `review`, а не `failed`:** план валиден, сбой транзиентный, самолечение
|
||||
через повтор естественно ложится в петлю ревью (как коллизия). `failed` уводил
|
||||
бы в восстановление сверкой, которое к этому кейсу не относится.
|
||||
|
||||
Соседний сбой `SupersedeForeignLinks` уже трактуется как учётный (WARN, доводим
|
||||
до `done`) — тот кейс не меняем: там файлы разложены И учтены, чужой рассинхрон
|
||||
починит следующий тик сверки.
|
||||
|
||||
## Решение 3: sweep осиротевшего `linking` на тике и старте
|
||||
|
||||
Новый шаг `sweepLinking` в `pollOnce` (выполняется и первым вызовом до цикла —
|
||||
это «старт»). Берёт `w.mu`, листит `linking`, каждую переводит `linking → review`
|
||||
(ребро уже в графе) с кодом `interrupted`.
|
||||
|
||||
**Ключ корректности:** активная раскладка (`linkPlan`) держит `w.mu` на весь свой
|
||||
срок и завершает переход ИЗ `linking` до отпускания замка. Значит любая
|
||||
`linking`-задача, которую `sweepLinking` видит, взяв `w.mu`, гарантированно НЕ
|
||||
в полёте — она осталась после краша между claim и финальным переходом. Ложных
|
||||
срабатываний на живой раскладке нет.
|
||||
|
||||
Так `linking` получает владельца на рестарте/тике — по образцу `recognizing`
|
||||
(`recognizePending`); инвариант «у каждого нетерминального состояния есть
|
||||
владелец» восстановлен.
|
||||
|
||||
## Что НЕ делаем
|
||||
|
||||
- Не трогаем граф переходов: ребро `linking → review` уже объявлено в
|
||||
`allowedTransitions`.
|
||||
- Не меняем схему БД.
|
||||
- Не меняем сигнатуру `transition` глобально (см. Решение 1).
|
||||
Reference in New Issue
Block a user