Имя, за которым две базы
Я запустил одну и ту же команду три раза подряд. Она посчитала применённые миграции и ответила: 66, пусто, 66.
Не «медленно», не «с ошибкой» — просто разные ответы на один вопрос за десять секунд. Ошибки, которые притворяются нестабильностью, — самые дорогие, потому что первым делом хочется списать их на случайность и запустить ещё раз. Я и списал. Дважды.
Откуда взялось два ответа
На машине живут несколько стендов, каждый со своим набором контейнеров, и все они подняты в одной сети. У контейнера базы моего проекта сетевое имя db. У базы соседнего сервиса — своё собственное имя, но вдобавок к нему тот же алиас db.
Дальше всё делает разрешение имён. Два контейнера отзываются на одно имя, порядок ответов не гарантирован, и приложение подключается то к своей базе, то к чужой — где, разумеется, ни одной знакомой таблицы нет.
Подлость в том, что обе базы называются одинаково. Спросить у приложения имя базы бесполезно — оно в обоих случаях отвечает одно и то же. Отличить попадание можно только по пропавшим таблицам, поэтому первые два прогона я и списал на случайность.
Проверяется одной командой к движку контейнеров: попросить у каждого контейнера список его сетевых псевдонимов. Мой отвечал [db db], соседний — [имя-соседа db]. Всё.
Что это испортило, кроме настроения
Ровно то, ради чего я туда лез. Я менял смысл одного признака в схеме и проверял поведение базы экспериментом: что произойдёт при записи NULL в колонку, объявленную NOT NULL. Замер — прямым запросом к контейнеру, отдельно под строгим режимом сервера, отдельно под тем режимом, который выставляет фреймворк на своём соединении. Оба раза одинаково: вставка отвергается.
А третью пробу — через сам фреймворк, то есть через весь стек — я запускал уже командой, которая ходит по имени. И вот она могла с равной вероятностью отработать не на той базе. Вывод получился правильным, но опирался на бросок монеты, и это выяснилось только когда та же команда на ровном месте не нашла таблицу миграций.
Лечится скучно: не полагаться на имя, а спросить у движка контейнеров адрес нужного контейнера и передать его приложению явно. Две строки в скрипте.
Отдельно приятно, что поймал это не человек, а собственный проверочный скрипт, который я же и написал получасом раньше — он честно напечатал «столбец rail отсутствует» там, где по всем моим представлениям он был обязан быть.
Второе, что оказалось не тем, чем считалось
Раз уж речь про эксперименты с базой. Есть распространённое убеждение: нестрогий режим прощает запись NULL в NOT NULL-колонку и молча превращает её в пустое значение. Половина правды.
Разберём по случаям, их всего четыре, и ни один не совпал с моим ожиданием целиком.
Неявная вставка — когда колонку просто не перечислили — в нестрогом режиме действительно проходит: строка встаёт, в колонке оказывается пустое значение, в ответ прилетает предупреждение. В строгом та же вставка отвергается ошибкой.
Явная вставка NULL отвергается всегда, в обоих режимах. Тут нестрогий режим не прощает ничего.
А вот обновление ведёт себя как неявная вставка, а не как явная: UPDATE ... SET колонка = NULL в нестрогом режиме не падает. Он выдаёт по предупреждению на строку, оставляет значение прежним и рапортует об успехе. В строгом — ошибка.
То есть граница проходит не там, где я думал. Не между явным NULL и неявным, а между вставкой и обновлением: строгий режим отвергает всё, нестрогий — только явную вставку.
Мне это стоило неработающего шага в миграции. Я написал последовательность «сначала перевести пустые строки в NULL, потом расширить колонку» — в такой логике всё выглядит стройно. Запустил, посмотрел: миграция прошла, ошибок нет. Только вот обновление не изменило ни одной строки, потому что колонка на тот момент ещё не принимала NULL. База честно предупредила — два предупреждения, по одному на строку, — и пошла дальше. Предупреждения никто не читает.
Это и был нестрогий режим на соединении фреймворка. Под строгим миграция упала бы там же с ошибкой, и я узнал бы о проблеме в ту же секунду, а не при разборе того, почему данные не поменялись.
Порядок пришлось развернуть: сначала расширяем колонку, потом чиним данные.
И третье, про необратимость
Пока правил миграцию, всплыло то, о чём знаешь в теории, но забываешь на практике: операции над структурой таблицы не откатываются транзакцией. Если миграция сняла старый уникальный ключ и упала на создании нового — она не «не выполнилась». Она оставила таблицу вообще без защиты от дублей, не записала себя в журнал применённых, и повторный запуск падает уже на первом шаге, потому что снимать больше нечего.
Я это не вычитал, а увидел: посадил две строки, которые под старым ключом жили законно, а под новым сталкиваются, и запустил. Таблица осталась без ключа.
Отсюда правило, которое стоит унести: проверку на конфликты в такой миграции ставят вплотную перед подменой ключа и заставляют её падать самостоятельно, с понятным текстом и перечислением конфликтующих групп. Не «пусть движок разберётся» — движок разберётся так, что чинить придётся руками.
И ещё: раз падение посреди возможно в принципе, каждый шаг должен переживать повторный запуск. Добавление колонки — под проверку «а её ещё нет», снятие ключа — под проверку «а он ещё есть». Иначе оператор, честно исправивший данные и запустивший миграцию заново, получит в лоб «колонка уже существует».
Что унести к себе
Если на машине больше одного стенда, имена контейнеров перестают быть уникальными раньше, чем вы об этом узнаете. Адрес базы для скриптов берите у движка контейнеров по имени контейнера, а не через разрешение сетевого имени.
Убеждения про поведение СУБД проверяйте экспериментом на копии таблицы — это пять минут, и ровно там выясняется, что «нестрогий режим простит» относится не к тому случаю, к которому вы его применяете.
А замер, который вы делали одним способом, а перепроверяли другим, — не замер. У меня из трёх проб две шли к контейнеру напрямую, а третья по имени, и именно третья была самой убедительной, потому что через весь стек. Она же оказалась единственной, за которую я не мог поручиться.