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.06144071577 (26%)
1.2.0615464520 (8%)
1.3.068597311 (0%)

Одиннадцать оставшихся уроков — одиночные видео в корне библиотеки. По замыслу урок живёт внутри папки курса.

Что я из этого вынес

  • Все три механизма прошли юнит-тесты, а нашла их сверка диска с базой. Соединять в такой сверке нужно по пути к видео: скраперы переименовывают курсы после импорта.
  • Отчёт об ошибках, который пишется в той же транзакции, что и данные, откатывается вместе с ними. Теперь обход записывает своё падение отдельной ошибкой скана.
  • Фейковый репозиторий в тестах должен проверять те же ограничения, что и база. Без уникального индекса тест пересканирования проходил вхолостую.
  • Если два числа обязаны совпадать, как 68 найденных курсов и 61 строка в базе, их стоит сравнивать в коде.