4 минуты
26% уроков пропали молча: разбор первого импорта course_shelf
Course Shelf — моя self-hosted библиотека видеокурсов. Она обходит папку с записями и собирает из неё каталог курсов, секций и уроков. Пока я гонял её на синтетических данных, всё было зелёным. Потом я направил её на настоящую библиотеку с домашнего NAS: 5984 видеофайла, 474 ГБ.
Скан закончился успехом. В каталоге появился 61 курс и 4407 уроков, интерфейс не показал ошибок, тесты остались зелёными. Потерялось 1577 уроков, чуть больше четверти.
Как я это увидел
Каталог выглядел правдоподобно: курсы на месте, в каждом есть уроки, в логах чисто. Потерю я нашёл сверкой: сравнил список видео на диске со списком уроков в базе, соединив их по пути к файлу.
Скрипт сверки поначалу ошибался в трёх местах:
commтребует файлы, отсортированные в C-локали. Безexport LC_ALL=Cон без единой ошибки выдаёт мусор.- Префикс пути внутри контейнера я сначала угадывал по одной строке, и на вложенных раскладках он съедал папку курса. Заменил его на наибольший общий префикс всех строк.
- Если не совпал ни один путь, скрипт теперь отказывается отчитываться о потере. Иначе сломанное соединение выглядит как стопроцентная потеря, и ты ищешь баг не там.
Исправленная сверка нашла три независимых механизма. Каждый из них в одиночку объяснял симптом, и на первом найденном легко было остановиться.
Механизм первый: позиции внутри секции
Позицию урока скан брал из номера в имени файла, а если номер не парсился,
ставил единицу: position = ordinal ?? 1. В базе стоит ограничение
@@unique([sectionId, position]). Два урока без номера в одной секции
получали одинаковую позицию, и второй падал на уникальном индексе.
Ограничение срабатывало: каждый потерянный урок бросал
LessonPositionConflictError, и скан записывал её в свой список ошибок. Этот список жил в агрегате Scan в памяти и уходил в базу одним
save() в самом конце обхода.
А этот финальный save() тоже падал. Одно видео весило 3 129 930 702 байта,
а столбец размера был Int, то есть 32-битный с пределом около двух
гигабайт. Запись размера переполнялась в той же транзакции, что и записи
ошибок, и откатывала их все. Скан так и остался в статусе running, без
статуса отказа и без единой записанной ошибки.
Починил в три шага:
- позиция теперь — ранг урока внутри секции по детерминированному ключу:
номер из имени, для имён вида
3.14составной номер, а без номера — полный путь к файлу. N уроков всегда дают N разных позиций; - оба столбца размера стали
BigInt; - внешний
catchобхода раньше был пустымcatch {}. Теперь он записывает падение как обычную ошибку скана.
Механизм второй: одноимённые файлы
Скан группирует файлы урока по имени без расширения: видео, субтитры и
материалы с одинаковым именем идут в один урок. Ключом группы был только
basename. В курсе с папками по модулям 01 Introduction.mp4 лежит в каждой
папке, и каждое следующее видео перетирало предыдущее в той же группе.
Так пропали 256 уроков. Код не считал это ошибкой: для него группа получала новое видео.
Механизм третий: кириллические названия
Функция toSlug вырезала всё, кроме [a-z0-9]. Название
«Графы и комбинаторика» превращалось в цепочку дефисов, дефисы обрезались,
оставалась пустая строка, и функция подставляла запасное значение untitled.
Все курсы без латиницы получали один и тот же slug.
Дальше скан проверял, не импортирован ли курс уже, по множеству известных
slug. Для второго курса с untitled скан находил там первый и пропускал его через
continue, не записывая ошибку. Из восьми таких курсов импортировался один,
семь пропали вместе с 253 уроками.
Скан при этом рапортовал coursesDiscovered: 68, а в базе лежала 61 строка.
Код эти два числа между собой не сравнивал.
Slug я сделал юникодным вместо транслитерации: графы-и-комбинаторика.
Курс нигде не адресуется по slug, все маршруты идут по cuid. У slug две
работы, уникальность и узнаваемость, и русскоязычному владельцу кириллица
даёт обе лучше, чем grafy-i-kombinatorika. Китайской или греческой
библиотеке подходит то же правило, без таблицы транслитерации на каждое
письмо.
Почему пересканирование не спасло
Для таких случаев в Course Shelf уже был пересканирующий эндпоинт
POST /courses/{id}/rescan. Обход пропускал курс, чей slug уже
лежит в базе, и этот пропуск стоял за 140 строк до единственного сохранения
урока. Для существующего курса пересканирование не записывало ни одного урока, и
библиотеку я вычищал сырым SQL.
У эндпоинта был тест «imports the named course», и он проходил. Фейковый репозиторий уроков в тестах не проверял уникальность
(sectionId, position), которую проверяет настоящая база. Тест проходил, не
импортируя ничего. Теперь фейк проверяет то же ограничение, что и Postgres.
По релизам
Все три замера — на одной библиотеке из 5984 видео:
| Релиз | Курсов | Уроков | Потеряно |
|---|---|---|---|
| 1.1.0 | 61 | 4407 | 1577 (26%) |
| 1.2.0 | 61 | 5464 | 520 (8%) |
| 1.3.0 | 68 | 5973 | 11 (0%) |
Одиннадцать оставшихся уроков — одиночные видео в корне библиотеки. По замыслу урок живёт внутри папки курса.
Что я из этого вынес
- Все три механизма прошли юнит-тесты, а нашла их сверка диска с базой. Соединять в такой сверке нужно по пути к видео: скраперы переименовывают курсы после импорта.
- Отчёт об ошибках, который пишется в той же транзакции, что и данные, откатывается вместе с ними. Теперь обход записывает своё падение отдельной ошибкой скана.
- Фейковый репозиторий в тестах должен проверять те же ограничения, что и база. Без уникального индекса тест пересканирования проходил вхолостую.
- Если два числа обязаны совпадать, как 68 найденных курсов и 61 строка в базе, их стоит сравнивать в коде.