Last active
January 10, 2023 17:30
-
-
Save leandro/e4de7695bafc36c165603f2e5da250cd to your computer and use it in GitHub Desktop.
Rakeable migrations experiment
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
module RakeableMigrations | |
class Base < ActiveRecord::Migration[6.1] | |
class << self | |
def migration_version = @migration_version | |
def set_migration_version(version) = @migration_version = version.to_s | |
end | |
end | |
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
require_relative 'base' | |
module RakeableMigrations | |
class FixTimelineActivityIndices < Base | |
disable_ddl_transaction! | |
set_migration_version '20230101184639' | |
def change | |
add_index :timeline_activities, [:tracked_id, :tracked_type], | |
name: 'index_timeline_activities_on_tracked', where: 'deleted_at IS NULL', | |
algorithm: :concurrently, if_not_exists: true | |
add_index :timeline_activities, [:recipient_id, :recipient_type], | |
name: 'index_timeline_activities_on_recipient', | |
where: 'deleted_at IS NULL', order: { activity_date: :desc }, | |
algorithm: :concurrently, if_not_exists: true | |
[:deleted_at, :firm_id, :activity_date, :parent_id, | |
[:owner_type, :owner_id], [:recipient_type, :recipient_id], | |
[:recipient_id, :recipient_type], [:related_to_type, :related_to_id], | |
[:tracked_id, :tracked_type] | |
].each { |indices| remove_index_with_opts(:timeline_activities, indices) } | |
end | |
private | |
def remove_index_with_opts(table, indices) | |
indices = Array(indices) | |
index_name = "index_#{table}_on_#{indices.join('_and_')}" | |
opts = { name: index_name, algorithm: :concurrently, if_exists: true } | |
remove_index(table, indices, **opts) | |
end | |
end | |
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# frozen_string_literal: true | |
class AsyncMigrations | |
attr_reader :direction, :force_run, :klass, :version | |
def initialize(klass, direction, force) | |
@direction = direction | |
@force_run = force | |
@klass = klass | |
@version = klass.migration_version | |
end | |
def run! | |
check_conditions! | |
execute_migration! | |
# If this task is being run from inside `db:migrate` or `db:migrate:*` we | |
# don't need to update ourselves the schema table nor update the | |
# `db/structure.sql` file. | |
return if rails_migration_task? | |
update_schema_migration! | |
update_schema_file! | |
end | |
private | |
def all_versions_run = (@all_versions_run ||= schema_migration.all_versions) | |
def down? = direction == :down | |
def migration_already_run? = version.in?(all_versions_run) | |
def schema_migration = ActiveRecord::Base.connection.schema_migration | |
def up? = direction == :up | |
def update_schema_file? = ActiveRecord::Base.dump_schema_after_migration | |
def check_conditions! | |
if version.blank? | |
raise RuntimeError, 'You need to provide migration version inside the '\ | |
'migration class with method `set_migration_version`' | |
elsif up? && migration_already_run? && !force_run | |
raise RuntimeError, 'This migration has already been run before' | |
elsif down? && !migration_already_run? && !force_run | |
raise RuntimeError, 'This migration has not been run before to be undone' | |
end | |
end | |
def execute_migration! | |
klass.new.exec_migration(ActiveRecord::Base.connection, direction) | |
end | |
def rails_migration_task? | |
Rake.application.top_level_tasks.first.start_with?('db:migrate') | |
end | |
def schema_table_updatable? | |
up? && !migration_already_run? || down? && migration_already_run? | |
end | |
def update_schema_file! | |
return unless update_schema_file? | |
db_config = ActiveRecord::Base.connection_db_config | |
ActiveRecord::Tasks::DatabaseTasks.dump_schema(db_config) | |
end | |
def update_schema_migration! | |
return unless schema_table_updatable? | |
migration_method = up? ? :create! : :delete_by | |
schema_migration.public_send(migration_method, version: version) | |
end | |
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
require 'async_migrations' | |
namespace :async_migrations do | |
RAKEABLE_MIGRATIONS_PATH = Rails.root.join('db/rakeable_migrations').freeze | |
RAKEABLE_MODULE_NAME = :RakeableMigrations | |
# === WHY | |
# | |
# This task has the main purpose of running "special" migrations that need | |
# a different care when being run in production environment. The most common | |
# use case for which you should use this task to run a migration is when you | |
# need to add indices to database tables that have a high volume of data in | |
# it, like 2M of rows or more. The reason for this is that for such scenarios, | |
# each index tends to take minutes to be added by the PostgreSQL server | |
# (some might take more than 15 minutes). And with that, if being done through | |
# an ordinary migration, it could make the CI deployment build to time out and | |
# cause us other types of issues. Since the application doesn't depend | |
# directly of this kind of migration to boot and run properly, it can be run | |
# manually after the deploy is done through this task. | |
# | |
# So, it's important that to stress this: you can only use this kind of | |
# migration when the application doesn't depend on the changes that the | |
# migration is going to bring, like table index changes. If you're going to | |
# change table structures that your rails app is going to count on, then you | |
# must use the normal migration flow, so it's run within the deployment | |
# process. | |
# | |
# === HOW | |
# | |
# In order to create this kind of migration you can follow the following steps | |
# below: | |
# | |
# 1. You first create your migration as you normally create any migration on | |
# rails, by running `rails g migration ...`. So lets, imagine that you just | |
# created `db/migrate/20230105214912_add_feature_codename_index.rb`. Also | |
# write down in there all the stuff you want your migration to do; | |
# 2. Then, once the migration is finished, you then **copy** it into the | |
# `db/rakeable_migrations/` directory; | |
# 3. Once you have now the file copied into `db/rakeable_migrations/` dir, you | |
# now you'll have to make a couple of small changes to the migration file. | |
# Considering the following code is the original migration you wrote: | |
# | |
# class AddFeatureCodenameIndex < ActiveRecord::Migration[6.1] | |
# def change | |
# add_index :permissions, :feature_codename | |
# add_index :feature_usages, :feature_codename | |
# end | |
# end | |
# | |
# You'll tweak into the following code: | |
# | |
# require_relative 'base' | |
# | |
# module RakeableMigrations | |
# class AddFeatureCodenameIndex < Base | |
# set_migration_version '20230105214912' | |
# | |
# def change | |
# add_index :permissions, :feature_codename | |
# add_index :feature_usages, :feature_codename | |
# end | |
# end | |
# end | |
# | |
# In essence, we did 4 changes in the original migration: | |
# | |
# 3.1. We loaded the file `db/rakeable_migrations/base.rb`; | |
# 3.2. We wrapped original migration class by `RakeableMigrations` | |
# module; | |
# 3.3. We changed migration class parent from | |
# `ActiveRecord::Migration[6.1]` to `Base`; | |
# 3.4. Then, we added line `set_migration_version '20230105214912'`. | |
# Notice the timestamp is the same as the one imprinted in the | |
# original migration file. This method doesn't exist in normal | |
# migrations and it's introduced when turned the migration class | |
# child of `Base` class included in item 3.1. That information is | |
# used to update the `schema_migrations` table when the migration is | |
# executed through this task (since we remove the timestamp from the | |
# migration file name after it's copied to `db/rakeable_migrations/` | |
# directory); | |
# | |
# 4. Then, remove the `20230105214912_` part of the migration filename. So, | |
# `db/rakeable_migrations/20230105214912_add_feature_codename_index.rb` | |
# will become `db/rakeable_migrations/add_feature_codename_index.rb`. | |
# 5. Now that you're done with your "rakeable migration", you can now go back | |
# to your original migration that was initially copied and change it into | |
# the following: | |
# | |
# class AddFeatureCodenameIndex < ActiveRecord::Migration[6.1] | |
# MIGRATION_NAME = 'add_feature_codename_index' | |
# | |
# def up | |
# return if Rails.env.production? | |
# | |
# Rake::Task['async_migrations:run'].invoke(MIGRATION_NAME, :up) | |
# end | |
# | |
# def down | |
# Rake::Task['async_migrations:run'].invoke(MIGRATION_NAME, :down) | |
# end | |
# end | |
# | |
# This way, this migration will really execute in all environments, except | |
# for production, where we want to run it manually through our rake task. | |
# 6. Once the you did production deployment, you then just need to run this | |
# migration through the following command (this must be run only for | |
# production environment): | |
# | |
# rails async_migrations:run['add_feature_codename_index','up',true] | |
# | |
# Notice that we're using as the first task argument the same name that we | |
# added as the constant value in `MIGRATION_NAME`, because it must | |
# reference the migration file name (without the '.rb' inside the | |
# `db/rakeable_migrations/`). And the 'up' as second argument because we | |
# want to run it in 'up' direction. And the last argument `true` is in | |
# order to force migration re-execution, given that a migration with same | |
# timestamp was already run before (during the deploy build in the CI). | |
# So, normally, if you don't force the execution like that and the task, | |
# when run, finds that the `schema_migrations` table already has version | |
# '20230105214912' inside it, the task will be halted with an error. But in | |
# this case we want to force re-execution so we can really run the actual | |
# migration. | |
desc 'Run given rakeable migration name' | |
task(:run, [:migration_file, :direction, :force] => :environment) do |t, args| | |
# @param migration_file [String] - the file name in db/rakeable_migrations/ | |
# without the directory and the ".rb" suffix | |
# @param direction [:down, 'down', :up, 'up'] - the migration direction. If | |
# not provided, `:up` is the default. | |
# @param force [true, false] - whether the migration execution should be | |
# forced or not. If not provided, `false` is the default | |
args.with_defaults(direction: :up, force: false) | |
direction = args[:direction].to_sym | |
force_run = args[:force] | |
migration_file = args[:migration_file] | |
file_path = File.join(RAKEABLE_MIGRATIONS_PATH, migration_file) | |
file_path = "#{file_path}.rb" unless file_path.end_with?('.rb') | |
unless File.exist?(file_path) | |
raise ArgumentError, "Migration file name '#{file_path}' does not exist" | |
end | |
constants_source = if Object.const_defined?(RAKEABLE_MODULE_NAME) | |
Object.const_get(RAKEABLE_MODULE_NAME) | |
else | |
Object | |
end | |
existing_classes = constants_source.constants | |
load(file_path) | |
added_class_name = (constants_source.constants - existing_classes).first | |
added_class = constants_source.const_get(added_class_name) | |
if constants_source == Object | |
base_class = added_class.const_get(:Base) | |
added_class_name = added_class.constants.find do |constant| | |
base_class.in?(added_class.const_get(constant).ancestors[1..]) | |
end | |
added_class = added_class.const_get(added_class_name) | |
end | |
AsyncMigrations.new(added_class, direction, force_run).run! | |
end | |
end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment