ActiveRecord Schema Consistency — а если проверю?

120967d7b9b6055ac5617618d350debf.png

Это ещё один текст по мотивам доклада на Ruby Russia 2022. Он посвящён консистентности схемы базы данных на примере библиотеки database_consistency. Автор — Евгений Демин, Principal Engineer и Ruby-разработчик Toptal.

Toptal, как и многие другие компании, сделала ставку на язык программирования Ruby и получила большой монолит. Каждый день, приходя на работу, я вижу очень много новых пул-запросов и новых коммитов от разных команд. Поскольку компания большая и большой монолит, у нас очень большая база данных и, соответственно, ActiveRecord. За пять лет работы над этим монолитом в компании Toptal случались разные ситуации.

Консистенции между ActiveRecord и базой данных

В 2018 году где-то в глубинах нашей ActiveRecord произошло падение со странной ошибкой.

9eca97e915291ec311974c3f7f291e3f.jpeg

Что происходит? Трудно понять, почему при обновлении одного поля у нас падает валидация по другому. 

898cbca2720da8b81d0ececc1c1763fb.jpeg

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

Как можно было этого избежать? Одно из очевидных решений — при добавлении валидации убедиться, что все записи в нашей таблице готовы к этой валидации.

Как это сделать? С помощью null constraint.

596c6b9877d2425e5d95db6004f58aaf.jpeg

К сожалению, любой может забыть добавить null constraint в процессе добавления валидации из-за большого объема работы. Поэтому мне стало интересно, как часто случаются такие ситуации, когда разработчик добавил валидацию, но не убедился, что в базе данных существует null constraint

8d44d8abdb7b2b96e79d1f85b3fa4424.jpeg

По результатам очень грубой оценки оказалось, что в нашем монолите их много. Это навело меня на мысль, что можно написать линтер, который автоматически искал бы похожие ситуации, и который в дальнейшем можно было бы расширять и добавлять в другие проекты. Такие ситуации я называю консистенциями между ActiveRecord схемой и базой данных.

v0.1.0 ColumnPresenceChecker 

v0.1.0 ColumnPresenceChecker — это первая проверка, которая появилась в библиотеке.

d821f6c404ea814ad214da9503451d48.jpeg

Допустим, у нас есть таблица users, а также класс Userс валидацией над полем name. В данном случае библиотека покажет, что не хватает null constraint над полем nameнашей таблицы.

c58f0dfb3566547e06d7424dcd0bf9c9.jpeg

Соответственно, нужна следующая схема баз данных:

00b388d17d866c93d7fe3c8de2f2b392.jpeg

Сделать это можно с помощью простой миграции. 

df9828835d847431edd20912abb1bf95.jpeg

ПРИМЕЧАНИЕ. Эта миграция не подходит для тех, кто следует Zero Downtime Deployment Policy.

В нашем монолите оказалось более 500 кейсов, что много даже для нашей базы данных. Но, как это часто бывает в больших компаниях, эта хорошая идея пришла в голову не только мне. Проблему решил коллега из другой команды вручную с помощью небольшого скрипта. Однако я решил продолжить писать гем, чтобы в дальнейшем его можно было расширять и добавлять другие проверки, а также выложить в Open Source, чтобы им могли пользоваться другие компании и индивидуальные разработчики. Спустя несколько месяцев произошел релиз гема, и он стал дополняться новыми проверками. 

v0.2.0 NullConstraintChecker

5877352b59b3ad2e9ad825b698ca40df.jpeg

NullConstraintChecker — это вторая проверка после ColumnPresenceChecker, которая делает то же самое, но в обратную сторону. Предположим, у нас есть null constraint, но нет валидации на модели. Это проблема. Например, пользователь использует один из наших интерфейсов, API или страницу и пытается заполнить какую-то форму, но не заполняет определенные значения. Если бы у нас была валидация, выпала бы ошибка 422 Unprocessable Entity, и на фронтенде мы могли бы ее соответствующим образом обработать. Однако, если мы не добавляем валидацию, сервер будет падать с 500-ой ошибкой, что не очень хорошо.

v. 0.4.0 BelongsToPresenceChecker

BelongsToPresenceChecker отслеживает, чтобы все неполиморфные ассоциации BelongsTo имели соответствующие foreign key constraint в базе данных.

f57a5d48fd8ecb22476d0c83f64d0c88.jpeg

Это очень полезная проверка, чтобы гарантированно иметь данные, связанные с нашей таблицей в связанных таблицах, чего без foreign key constraint мы гарантировать не можем.

v. 0.5.0 MissingUniqueIndexChecker

Мы все знаем, что валидация на уникальность сама по себе уникальность не гарантирует. Во-первых, может быть Race Condition, во-вторых, данные могли быть сохранены с выключенной валидацией, в-третьих, что угодно могло пойти не так. Без соответствующего уникального индекса, который покрывает нашу валидацию, уникальность мы гарантировать не можем, и было бы хорошо иметь этот индекс в нашей таблице. Данная проверка смотрит, покрыта ли валидация на уникальность этими индексами.

6aa201f240a340850a5ff32fd1c65a31.jpeg

В начале 2019 года я встретил конкурента. Сначала мне захотелось присоединиться к существующему гему, который на тот момент был уже достаточно популярен, и у него было уже несколько проверок. Однако коллеги, в том числе Михаил Папис, создатель RVM, подсказали, что здоровая конкуренция — это здорово, как и свое видение какой-то проблематики и создание своего продукта. Поэтому я решил продолжить разрабатывать собственный гем.

В этот момент в нашей компании произошла новая неочевидная проблема.

v0.6.0 MissingIndexChecker

Предположим, у нас есть простая таблица users, таблица accounts с user_id, модель Users с has_one :account и модель Accounts с belongs_to

4aa84f5a5c72e09dfd9315b4d424a533.jpeg

Казалось бы, ничего не предвещает беды. ActiveRecord работает как часы, для каждого пользователя возвращаются аккаунты.

1a02cf664e1a1959c1d3889efa472b61.jpeg

Однако для конкретного пользователя, когда мы выполняем joins, почему-то отображается два аккаунта, то есть всего две записи.

815890bf8876ac4b0696ebbfb93e1db2.jpeg

Чего же не хватает? Это не очевидно, и не все знают об этой проблеме по умолчанию. Все очень просто. has_one не гарантирует, что будет 0 или 1 связь с нашей базой данных. Поэтому желательно иметь соответствующий уникальный индекс над нашим связующим вторичным ключом, что не сделано в первоначальной схеме.

84ffb9e61dfb26a456b776911fa350cc.jpeg

Отсутствие индекса может привести к очень странным последствиям. Например, часть нашей программы может отлично работать, но в конкретных ситуациях все может посыпаться и потребовать усилий, чтобы все исправить.

Индекс в схеме должен быть уникальным.

509f3d24b87bffc112155d92aa683d32.jpeg

Добиться этого можно простой миграцией.

f847fac8590c10d3f29c590f27e400a0.jpeg

Пролетел еще один год и вышли новые проверки.

v0.7.0 LengthConstraintChecker

Данная проверка смотрит, чтобы все наши типы данных, которые имеют определенные лимиты, например, varchar (128), в нашей базе данных, имели соответствующие валидации на длину в наших моделях.

dd66ef538b8fe5de153dff559e764234.jpeg

v. 0.8.0 PrimaryKeyTypeChecker

Начиная с пятой версии Rails, выходит обновление, предусматривающее, что все первичные ключи по умолчанию имеют большой тип данных, например, BIGINT или BIG SERIAL. К сожалению, в каких-то проектах первичные ключи имеют меньший тип данных. Это нормально, поскольку не так-то просто переполнить 2 миллиарда первичных ключей. Однако это можно сделать, и после переполнения это приведет к неординарным проблемам.

В конце 2020 года Дэвид Хенсон, создатель Rails, больше известный, как DHH, рассказал, как все рассыпалось в Basecamp. Они столкнулись с переполнением типа для вторичного ключа, то есть первичный ключ имел больший тип данных, чем вторичный. Соответственно, когда были добавлены новые записи для таблицы, все было хорошо, но в связывающей таблице вторичный ключ переполнился. Связь была утеряна или же указывала на другую запись в первичной таблице (что, возможно, еще хуже). 

Поэтому я решил написать валидатор для DHH, чтобы находить такие ситуации автоматически. Мы это сделали в рамках v0.8.5 ForeignKeyTypeChecker.

v0.8.5 ForeignKeyTypeChecker

Предположим, у нас есть таблица users, таблица account с полем user_id, модель User с ассоциацией с has_one :account и модель Accountsс belongs_to :user.

65030867f915954d28174883eb606566.jpeg

В данном случае ошибка библиотеки выдаст ошибку, что наш тип данных для вторичного ключа недостаточен для покрытия типа данных первичного ключа.

1d533c134e1e4e0362a0dc2f3aacd0b2.jpeg

Исправить это можно примерно следующим образом:

9da7942187139cdd1684be86ee6af62a.jpeg

Необходимо перейти от .integer к .bigint для user_id в таблице accounts. Сделать это можно следующий миграцией:

7b81fa050740f974b2454bc9e88e36d4.jpeg

© Habrahabr.ru