Skip to content

Instantly share code, notes, and snippets.

@leandro
Last active January 10, 2023 17:30
Show Gist options
  • Save leandro/e4de7695bafc36c165603f2e5da250cd to your computer and use it in GitHub Desktop.
Save leandro/e4de7695bafc36c165603f2e5da250cd to your computer and use it in GitHub Desktop.
Rakeable migrations experiment
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
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
# 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
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