Как мигрировать данные в Rails-приложении
Rails-миграции подходят для маленьких и коротких миграций. В идеале без downtime. Это разумный выбор для нового проекта. База данных относительно маленькая, пользователей мало. Downtime - не проблема. Бага - тоже не страшно.
Но с ростом проекта меняются и требования. Становится критичным качество, поэтому миграции данных надо тестировать. База разрастается, поэтому миграции данных идут дольше. Теперь данные не мигрирует при деплое. Downtime уже не вариант, разве что когда у пользователей ночь. И со временем проекты уходят от миграций Rails. Стандартный выбор - Rake-задачи.
Плюсы подхода с Rake-задачами:
- легко написать unit-тесты
- запускать вручную и больше одного раза
- а затем удалить после релиза
Единственный минус - Rake-задачи надо запускаются вручную и на каждом окружении (production, staging). Появляется человеческий фактор, а значит риск ошибиться. Тратятся время и нервы инженеров.
Но Rake-задачи не единственный выход. Появилась куча gem‘ов, которые берутся решать проблемы как миграций Rails, так и подхода с Rake-задачами. Рассмотрим их внимательнее.
DISCLAIMER: Я не пользовался этими gem‘ами в production‘е.
Содержание
data-migrate
https://github.com/ilyakatz/data-migrate
data-migrate решает только одну проблему - переносит миграции данных в отдельную директорию.
Ключевые моменты:
- миграции данных - в отдельной директории db/data
- список запущенных миграций хранится в таблице базы данных data_migrations, аналогичной стандартной в Rails таблице schema_migrations
- в комплекте идут 22 Rake-задачи, аналогичные стандартным:
- для запуска и отката конкретной миграции,
- получить статус миграций
- создать или применить файл db/data_schema.rb со списком миграций итд
- можно накатить отдельно миграции данных - командой
rake data:migrate
- можно накатать сразу и миграции данных и схемы в правильном порядке (отсортированные по timestamp‘у) командой
rake db:migrate:with_data
- 1к звезд на GitHub, 7M скачиваний на RubyGems
После добавления gem‘а в проект при первом запуске миграций данных data-migrate автоматически создаст таблицу data_migrations и файл db/data_schema.rb.
Если создать пустой Rails-проект, добавить data-migrate и запустить bin/rails db:migrate
и затем bin/rails data:migrate
, то база будет выглядеть так (на примере SQLite):
sqlite> .fullschema
CREATE TABLE IF NOT EXISTS "schema_migrations" ("version" varchar NOT NULL PRIMARY KEY);
CREATE TABLE IF NOT EXISTS "ar_internal_metadata" ("key" varchar NOT NULL PRIMARY KEY, "value" varchar, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL);
CREATE TABLE IF NOT EXISTS "data_migrations" ("version" varchar NOT NULL PRIMARY KEY);
Создадим таблицу для экспериментов:
class CreateUsers < ActiveRecord::Migration[7.0]
def change
create_table :users do |t|
t.string :full_name
t.timestamps
end
end
end
И затем первую миграцию данных. Сгенерируем файл командой:
$ bin/rails g data_migration AnonymizeUserFullName
Отметим, что созданная миграция данных ничем не отличается от миграции Rails:
# frozen_string_literal: true
class AnonymizeUserFullName < ActiveRecord::Migration[7.0]
def up
end
def down
raise ActiveRecord::IrreversibleMigration
end
end
Добавим SQL-запрос для модификации колонки таблицы:
def up
execute <<~SQL
update users set full_name = 'John Doe'
SQL
end
Запустим миграцию данных:
$ bin/rails data:migrate
== 20220204001632 AnonymizeUserFullName: migrating ============================
-- execute("update users set full_name = 'John Doe'\n")
-> 0.0055s
== 20220204001632 AnonymizeUserFullName: migrated (0.0056s) ===================
Запущенная миграция сразу отражается в файле db/data_schema.rb
DataMigrate::Data.define(version: 20220204001632)
Итоговая структура файлов в директории db:
▾ db/
▾ data/
20220204001632_anonymize_user_full_name.rb
▾ migrate/
20220204001454_create_users.rb
data_schema.rb
schema.rb
Проверим, как поддерживается порядок запуска миграций схемы и данных. Добавим новую миграцию схемы, которая создает таблицу projects. Затем пересоздадим базу данных и накатим все миграции с нуля командой rake db:migrate:with_data
$ bin/rails db:migrate:with_data
== Schema =====================================================================
== 20220204001454 CreateUsers: migrating ======================================
-- create_table(:users)
-> 0.0016s
== 20220204001454 CreateUsers: migrated (0.0017s) =============================
== Data =======================================================================
== 20220204001632 AnonymizeUserFullName: migrating ============================
-- execute("update users set full_name = 'John Doe'\n")
-> 0.0158s
== 20220204001632 AnonymizeUserFullName: migrated (0.0159s) ===================
== Schema =====================================================================
== 20220204002538 CreateProjects: migrating ===================================
-- create_table(:projects)
-> 0.0017s
== 20220204002538 CreateProjects: migrated (0.0018s) ==========================
Как видим, миграции запустились в правильном порядке - сначала создается таблица users, затем миграция данных, а затем создается таблица projects. Все упорядочены по времени создания.
Проверим статус миграций:
$ bundle exec rake db:migrate:status:with_data
database:
Status Type Migration ID Migration Name
------------------------------------------------------------
up schema 20220204001454 Create users
up data 20220204001632 Anonymize user full name
up schema 20220204002538 Create projects
Все миграции (и схемы и данных) отмечены в одном списке как примененные.
after_party
https://github.com/theSteveMitchell/after_party
after_party решает главную проблему подхода с Rake-задачами - ручной запуск новых Rake-задач с миграциями данных при деплое.
Ключевые моменты:
- миграции - это Rake-задачи
- миграции - в отдельной директории lib/tasks/deployment/
- запущенные миграции трекаются в отдельной таблице task_records в базе данных, аналогичной стандартной в Rails таблице schema_migrations
- при деплое выполняют команду
rake after_party:run
, которая запускает все еще не примененные в этом окружении Rake-задачи с миграциями данных - миграцию данных запускают после Rails миграций схемы.
- ~100 звезд на GitHub, 1M скачиваний на RubyGems
При добавлении gem’ надо настроить - сгенерировать конфигурационный файл и миграцию с созданием таблицы task_records:
$ rails generate after_party:install
create config/initializers/after_party.rb
create db/migrate/20220212173610_create_task_records.rb
Новый конфигурационный файл:
# config/initializers/after_party.rb
AfterParty.setup do |config|
# ==> ORM configuration
# Load and configure the ORM. Supports :active_record (default) and
# :mongoid (bson_ext recommended) by default. Other ORMs may be
# available as additional gems.
require 'after_party/active_record.rb'
end
Миграция с созданием таблицы task_records:
# db/migrate/20220212173610_create_task_records.rb
class CreateTaskRecords < ActiveRecord::Migration[7.0]
def change
create_table :task_records, :id => false do |t|
t.string :version, :null => false
end
end
end
Структура аналогична таблице schema_migrations - сохраняется только версия (timestamp) запущенных миграций.
Запустим Rails миграции, чтобы создать таблицу:
$ bin/rails db:migrate
== 20220212173610 CreateTaskRecords: migrating ================================
-- create_table(:task_records, {:id=>false})
-> 0.0011s
== 20220212173610 CreateTaskRecords: migrated (0.0011s) =======================
Создадим миграцию данных (и предварительно создадим еще таблицу users как в прошлом примере):
$ bin/rails generate after_party:task update_user_full_names
create lib/tasks/deployment/20220212174826_update_user_full_names.rake
Сгенерировалась новая Rake-задача:
namespace :after_party do
desc 'Deployment task: update_user_full_names'
task update_user_full_names: :environment do
puts "Running deploy task 'update_user_full_names'"
# Put your task implementation HERE.
# Update task as completed. If you remove the line below, the task will
# run with every deploy (or every time you call after_party:run).
AfterParty::TaskRecord
.create version: AfterParty::TaskRecorder.new(__FILE__).timestamp
end
end
Повторим наш пример с миграцией таблицы users - меняем значение колонки full_name:
ActiveRecord::Base.connection.update("update users set full_name = 'John Doe'")
Запускаем миграцию данных:
$ bundle exec rake after_party:run
Running deploy task 'update_user_full_names'
Это зафиксировалось в статусе миграций:
bundle exec rake after_party:status
Status Task ID Task Name
--------------------------------------------------
up 20220212174826 Update user full names
Повторный запуск миграций игнорирует уже запущенную миграцию:
$ bundle exec rake after_party:run
no pending tasks to run
after_party решает некоторые проблемы миграций Rails:
- миграции данных находятся в отдельной директории (lib/tasks/deployment)
- миграцию можно легко протестировать - раз это стандартная Rake-задача
- миграцию можно запустить руками повторно
- длинную миграцию можно запустить асинхронно при деплое с помощью команды
nohup <command> &
Я бы отметил одну очевидную проблему с таким подходом. В общем случае не выйдет мигрировать старую или отставшую базу данных, например бэкап. Нельзя запустить миграции схемы и миграции данных за нескольких релизов в том же порядке, в каком они прошли на production‘е, так как мы можем запустить их только отдельно. Сначала все миграции схема, а затем все миграции данных. Нам же надо, чтобы сначала запустились миграции одного релиза, потом следующего и так до самого конца.
Рассмотрим вариант с переносом столбца из одной таблицы в другую. В релизе 1 в миграции схемы мы создали новый столбец в таблице и в миграции данных скопировали значение из другой таблицы. В релизе 2 мы удаляем старый столбец. А теперь представим, что у нас есть дамп базы созданный до релиза 1 и нам надо обновить его до текущего состояния. Мы запускаем миграции Rails, которые создают новый столбец и затем удаляют старый. Затем мы запускаем миграции данных и наша миграция копирования столбца падает, так как старый столбец уже удален.
migration_data
https://github.com/ka8725/migration_data
Довольно скромный gem migration_data создавался, чтобы решить все проблемы миграций Rails (статья автора). Но в итоге только упрощает тестирование. Вернее только подключение файла миграции в тесте.
Ключевые моменты:
- дополняет механизм миграций Rails и добавляет несколько методов к стандартным
up
,down
иchange
:data
rollback
data_before
,data_after
rollback_before
,rollback_after
- появляется тестовый helper
require_migration
- ~300 звезд на GitHub, 1M скачиваний на RubyGems
Создадим миграцию для таблицы users:
class MigrateUserFullNames < ActiveRecord::Migration[7.0]
def change
end
def data
execute <<~SQL
update users set full_name = 'John Doe'
SQL
end
end
В ней появляется метод data, в который и помещается код миграции данных. Метод change
остается пустым.
Запустим миграцию:
$ bin/rails db:migrate
== 20220212205020 MigrateUserFullNames: migrating =============================
-- execute("update users set full_name = 'John Doe'\n")
-> 0.0012s
== 20220212205020 MigrateUserFullNames: migrated (0.0012s) ====================
Метод change
можно опустить и оставить только миграцию данных
class MigrateUserFullNames < ActiveRecord::Migration[7.0]
def data
execute <<~SQL
update users set full_name = 'John Doe'
SQL
end
end
Теперь давайте посмотрим как это тестировать. Инстанцируем класс миграции (без параметров) и вызываем метод data
:
require 'rails_helper'
require 'migration_data/testing'
require_migration 'migrate_user_full_names'
RSpec.describe MigrateUserFullNames do
describe '#data' do
it 'updates full_name attribute' do
user = User.create!(full_name: 'Robin Hood')
described_class.new.data
expect(user.reload.full_name).to eq('John Doe')
end
end
end
Метод require_migration
подключает нашу миграцию по имени, без версии и пути:
require_migration 'migrate_user_full_names'
Тест успешно запускается и проходит.
$ bundle exec rspec spec/db/migrations/migrate_user_full_names_spec.rb
-- execute("update users set full_name = 'John Doe'\n")
-> 0.0001s
.
Finished in 0.01534 seconds (files took 2 seconds to load)
1 example, 0 failures
Это равносильно прямолинейному require
:
require './db/migrate/20220212205020_migrate_user_full_names'
Такой тест использует текущую схему базы данных, и не выполняет откат до версии, на которой должна запускаться миграция данных. Если схема таблиц, вовлеченных в миграцию, изменилась (например, удалили или переименовали столбец), тест начнет падать.
На мой взгляд этот gem не решает ни одну из проблем миграций Rails.
rails-data-migrations
https://github.com/OffgridElectric/rails-data-migrations
rails-data-migrations похож на data-migrate и решает ту же самую проблему - переносит миграции данных в отдельную директорию.
Ключевые моменты:
- миграции - в отдельной директории db/data_migrations
- список запущенных миграций хранится в таблице базы данных data_migrations, аналогичной стандартной в Rails таблице schema_migrations
- есть несколько незадокументированных Rake-задач с говорящими названиями:
data:reset
data:migrate:up
data:migrate:down
data:migrate:skip
data:migrate:pending
- можно накатить только миграции данных -
rake data:migrate
- ~100 звезд на GitHub, 200k скачиваний на RubyGems
После добавления gem‘а в проект при первом запуске миграций данных rails-data-migrations автоматически создаст таблицу data_migrations.
Структура таблиц в базе:
sqlite> .fullschema
CREATE TABLE IF NOT EXISTS "schema_migrations" ("version" varchar NOT NULL PRIMARY KEY);
CREATE TABLE IF NOT EXISTS "ar_internal_metadata" ("key" varchar NOT NULL PRIMARY KEY, "value" varchar, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL);
CREATE TABLE IF NOT EXISTS "data_migrations" ("version" varchar NOT NULL PRIMARY KEY);
Сгенерируем первую миграцию данных:
$ rails generate data_migration MigrateUserFullNames
create db/data_migrations/20220213001232_migrate_user_full_names.rb
Новая миграция выглядит следующим образом:
class MigrateUserFullNames < ActiveRecord::DataMigration
def up
# put your code here
end
end
Добавим нашу миграцию таблицы users:
class MigrateUserFullNames < ActiveRecord::DataMigration
def up
execute <<~SQL
update users set full_name = 'John Doe'
SQL
end
end
И запустим командой rake data:migrate
:
$ bundle exec rake data:migrate
== 20220213001232 MigrateUserFullNames: migrating =============================
-- execute("update users set full_name = 'John Doe'\n")
-> 0.0012s
== 20220213001232 MigrateUserFullNames: migrated (0.0013s) ====================
Вроде бы все правильно отработало.
Я позапускал доступные Rake-задачи и заметил, что rake data:migrate:down
работает неправильно. Эта команда должна откатить конкретную миграцию, но в таблице data_migrations версия миграции не удаляется. И последующая накатка миграции командой rake data:migrate:up
завершается ошибкой:
$ bundle exec rake data:migrate:down VERSION=20220213001232
$ bundle exec rake data:migrate:up VERSION=20220213001232
== 20220213001232 MigrateUserFullNames: migrating =============================
-- execute("update users set full_name = 'John Doe'\n")
-> 0.0012s
== 20220213001232 MigrateUserFullNames: migrated (0.0012s) ====================
rake aborted!
StandardError: An error has occurred, this and all later migrations canceled:
SQLite3::ConstraintException: UNIQUE constraint failed: data_migrations.version
/Users/andrykonchin/.rbenv/versions/3.0.0/bin/bundle:23:in `load'
/Users/andrykonchin/.rbenv/versions/3.0.0/bin/bundle:23:in `<main>'
Caused by:
ActiveRecord::RecordNotUnique: SQLite3::ConstraintException: UNIQUE constraint failed: data_migrations.version
/Users/andrykonchin/.rbenv/versions/3.0.0/bin/bundle:23:in `load'
/Users/andrykonchin/.rbenv/versions/3.0.0/bin/bundle:23:in `<main>'
Caused by:
SQLite3::ConstraintException: UNIQUE constraint failed: data_migrations.version
/Users/andrykonchin/.rbenv/versions/3.0.0/bin/bundle:23:in `load'
/Users/andrykonchin/.rbenv/versions/3.0.0/bin/bundle:23:in `<main>'
Tasks: TOP => data:migrate:up
(See full trace by running task with --trace)
Очевидно, что при накатывании миграции ее версия сохраняется в таблице data_migrations. Но там уже есть эта версия добавленная в предыдущий раз. Зарепортил багу автору на GitHub (issue)
nonschema_migrations
https://github.com/jasonfb/nonschema_migrations
nonschema_migrations - использует подход data-migrate. Главная цель - вынести миграции данных в отдельную директорию.
Ключевые моменты:
- миграции данных находятся в отдельной директории db/data_migrate
- список запущенных миграций хранится в таблице базы данных data_migrations, аналогичной стандартной в Rails таблице schema_migrations
- в комплекте идет набор Rake-задач:
rake data:migrate
rake data:rollback
rake data:migrate:down
rake data:migrate:up
- можно накатить только миграции данных отдельно командой
rake data:migrate
- ~60 звезд на GitHub, ~20k скачиваний на RubyGems
Для работы gem‘а надо сгенерировать миграцию с созданием таблицы data_migrations:
$ rails generate data_migrations:install
create db/migrate/20220213202651_create_data_migrations.rb
Новая миграция:
class CreateDataMigrations < ActiveRecord::Migration[7.0]
def self.up
create_table :data_migrations, id: false do |t|
t.string :version
end
end
def self.down
drop_table :data_migrations
end
end
Далее сгенерируем миграцию данных для таблицы users:
$ rails generate data_migration MigrateUserFullNames
create db/data_migrate/20220213202952_migrate_user_full_names.rb
Получился вот такой файл:
class MigrateUserFullNames < ActiveRecord::Migration[7.0]
def change
end
end
Добавим SQL-запрос:
class MigrateUserFullNames < ActiveRecord::Migration[7.0]
def change
execute <<~SQL
update users set full_name = 'John Doe'
SQL
end
end
И запустим миграцию:
$ bundle exec rake data:migrate
== 20220213202952 MigrateUserFullNames: migrating =============================
-- execute("update users set full_name = 'John Doe'\n")
-> 0.0017s
== 20220213202952 MigrateUserFullNames: migrated (0.0018s) ====================
Как видим, все работает. Правильно и просто. Пользоваться можно.
Выводы
Как видим, нам предлагают всего два подхода - копия механизма миграций Rails и его вариация с Rake-задачами. Не густо, скажу я вам. Выделяются два gem‘а - data-migrate, так как к нему прилагается неприлично большой набор Rake-задач и на GitHub стоит много звезд, и after_party - оригинальностью подхода с Rake-задачами. Остальные gem‘ы отстают по набору Rake-задач, поддержке и популярности.
Мне нравятся оба варианта, правда подход after_party немного больше.
Но есть проблемы или недоработки в обоих реализациях. Как минимум нужна возможность в unit-тесте откатить схему таблиц назад до актуальной для миграции версии. Также в after_party нельзя запустить сразу и миграции схемы и миграции данных вперемешку, но упорядоченно по времени создания.