layout: непомещающееся целевое имя уводит задачу в review вместо failed

- предел длины компонента (255 байт) проверяется в BuildLinks до первой
  операции с ФС: ни каталога, ни ссылки при отказе не создаётся
- причина пустого предпросмотра считается на показе (ReviewData.PreviewError)
  и печатается в панели действий и в карточке Telegram: у задачи без
  записанной причины взять её больше неоткуда
This commit is contained in:
av
2026-08-10 12:16:44 +03:00
parent 1710e5a9d5
commit b9f0929d0c
31 changed files with 1511 additions and 17 deletions
@@ -0,0 +1,106 @@
## Context
`layout.BuildLinks` — чистая функция от плана: строит целевые пути, проверяет
выход за библиотеку и не трогает диск. Длину имени она не проверяет, поэтому
слишком длинное название доезжает до `Apply`, где `os.MkdirAll` или `os.Link`
получают от ядра `ENAMETOOLONG`. `worker.linkPlan` разбирает исход `Apply` двумя
ветками: `layout.ErrCollision``review`, всё остальное → `failed` с текстом
ошибки в `error_msg`. Системная ошибка попадает во вторую.
Путь «отказ построения → review» в проекте уже есть: ошибка `BuildLinks` уводит
задачу в `review` с кодом `build` (`worker/review.go`). То есть чинить надо не
маршрут, а место проверки и различимость причины.
Предел длины на ext4/xfs/btrfs — 255 **байт** на компонент (`NAME_MAX`), а не
символов: кириллическое название в UTF-8 тратит по два байта на букву и
упирается вдвое раньше, чем кажется по числу знаков.
## Goals / Non-Goals
**Goals:**
- Непомещающееся имя даёт `review` с доменной причиной, а не `failed` с текстом
ядра.
- Отказ целиком: ни каталога, ни ссылки. Проверка стоит до первой операции с ФС.
- Причина различима кодом — по ней видно, чем этот вход в `review` отличается от
коллизии и от прочих отказов построения.
**Non-Goals:**
- **Не обрезаем и не переименовываем название сами.** Это решение человека, и
подсказка названия на ревью для него уже есть. Автоматическая обрезка дала бы
папку, которую правило сходимости потом унаследует навсегда.
- **Не выясняем предел у ядра** (`pathconf(_PC_NAME_MAX)`, пробная запись).
Проверка обязана отвечать до обращения к диску, а на разных подкаталогах
ответы могут различаться.
- **Не проверяем предел длины пути целиком** (`PATH_MAX`, 4096 байт). Корни
библиотек задаёт оператор, глубина у нас фиксированная (папка тайтла + папка
сезона + файл), и трёх компонентов по 255 байт до 4096 не хватает.
## Decisions
**Решение 1. Проверка живёт в `BuildLinks`, по компонентам относительного
пути.** `BuildLinks` уже единственное место, где целевой путь собирается целиком
и проверяется на выход за библиотеку, — вторая проверка того же пути встаёт
рядом с первой. Проверяются компоненты **под корнем библиотеки**, а не весь путь:
корни задаёт оператор, и жаловаться на его каталоги раскладка не вправе.
**Решение 2. Предел — константа 255 байт с объяснением, откуда взята.**
`NAME_MAX` у ext4, xfs и btrfs — 255 байт; это предел самой распространённой
раскладки, а не универсальный закон. Значение стоит именованной константой в
`internal/layout` с комментарием, называющим источник. В конфиг не выносится:
настройка, которую никто не знает, как выставить, — это не гибкость.
**Решение 3. Своя ошибка `layout.ErrNameTooLong`, обёрнутая как остальные.**
`errors.Is` по ней ловит случай на любой глубине обёртки — так же, как
`ErrCollision`. Текст ошибки называет компонент и его длину в байтах: без числа
человек не поймёт, насколько сокращать.
**Решение 4. Свой код причины `name_too_long` рядом с `collision`.** Ошибка
`BuildLinks` сегодня даёт код `build` — он верен, но объединяет непомещающееся
имя с пустым названием и серией без номера. Код причины в этом проекте —
корреляционный ключ шага, и слепить три разных отказа в один значит потерять
возможность отличить чинимое подсказкой от нечинимого. Ветка встаёт в `linkPlan`
перед общей веткой `build`.
**Решение 5. Проверка длины идёт после проверки выхода за библиотеку.**
Путь, вылезший из песочницы, — это `critical`; сообщить о нём длиной имени
значило бы скрыть находку безопасности за косметической причиной. Порядок
проверок в цикле сохраняет приоритет.
**Решение 6. Превью получает проверку даром — и это довод за `BuildLinks`.**
Оба предпросмотра ревью (карточка и строка источника) строят пути тем же
`BuildLinks` (`worker/review.go`), поэтому вердикт на показе и вердикт на
применении совпадают по устройству, а не по договорённости. Цена — видимая:
непомещающееся имя даёт пустой предпросмотр, а панель действий при пустом
предпросмотре прячет «Применить» и печатает общий текст «Подтверди источник».
Баннер причины над карточкой при этом верен. Поведение не новое — так уже ведут
себя все отказы построения плана, — но это изменение делает его достижимым на
штатном входе.
## Risks / Trade-offs
- **Предел зашит и может не совпасть с реальной ФС.** На ФС с меньшим
`NAME_MAX` (например, некоторые зашифрованные) имя пройдёт проверку и упрётся в
ядро — тот же `failed`, что сегодня, но уже только на редкой раскладке. На ФС с
бо́льшим пределом мы отказываем строже, чем нужно. Обе стороны названы в
комментарии константы; лечение — правка константы, а не настройка.
- **Причина различима, но не самообъясняема.** Человек видит «имя не
помещается» и длину — но не видит, на сколько байт сокращать после
provider-тега и суффикса серии. Считать это за него — отдельная работа, здесь
она не делается.
- **Изменение сдвигает часть нынешних `failed` в `review`.** Это и есть цель, но
задачи, лежащие в `failed` с этой ошибкой прямо сейчас, сами туда не переедут и
сверкой не подхватятся: из `failed` их выводит только `retry`, а он тянет
полный цикл через `downloading` и распознавание. Обещания самоизлечения здесь
нет.
- **Унаследованная база отказа по длине не даёт, и это арифметика, а не
везение.** На чекпоинте эта строка стояла как признанный тупик: база берётся с
диска, подсказка её не укорачивает, задача циклится. Попытка написать тест
показала, что случай недостижим. Папка-якорь лежит на диске, значит её имя уже
не длиннее предела; хвост имени файла — `" S02E01"` плюс расширение, 11 байт —
не длиннее хвоста имени папки, `" ["` плюс provider-тег плюс `"]"`, минимум 11
байт (`tmdbid-1`). Язык и флаги субтитров и двойные серии в `layout.PlanFile`
из распознавания не приходят вовсе (`toLayoutPlan` их не заполняет), поэтому
длиннее не станет. Свойство держит тест `TestApply_InheritedBaseAtLimitStillFits`:
если суффиксы вырастут, он покраснеет раньше, чем задача упрётся в тупик.