-
Notifications
You must be signed in to change notification settings - Fork 7
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Support non-linear phase dependencies #122
Changes from 6 commits
d2219b8
a961849
1cc48fc
955731d
74cf67c
df619e8
1970150
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
|
@@ -2,59 +2,114 @@ | |||||
|
||||||
[![Test](https://github.com/procore-oss/handcuffs/actions/workflows/test.yaml/badge.svg?branch=main)](https://github.com/procore-oss/handcuffs/actions/workflows/test.yaml) | ||||||
[![Gem Version](https://badge.fury.io/rb/handcuffs.svg)](https://badge.fury.io/rb/handcuffs) | ||||||
[![Discord](https://img.shields.io/badge/Chat-EDEDED?logo=discord)](https://discord.gg/PbntEMmWws) | ||||||
[![Discord](https://img.shields.io/badge/Chat-EDEDED?logo=discord)](https://discord.gg/PbntEMmWws) | ||||||
|
||||||
Handcuffs provides an easy way to run migrations in phases in your [Ruby on Rails](https://rubyonrails.org/) application. | ||||||
Handcuffs provides an easy way to run [Ruby on Rails](https://rubyonrails.org/) migrations in phases using a simple process: | ||||||
|
||||||
To configure, first create a handcuff initializer and define a configuration | ||||||
1. Define a set of named phases in the order in which they should be run | ||||||
2. Tag migrations with one of the defined phase names | ||||||
3. Run migrations by phase at start, end or outside of application deployment | ||||||
|
||||||
|
||||||
## Installation | ||||||
|
||||||
Add this line to your application's Gemfile: | ||||||
|
||||||
```ruby | ||||||
gem 'handcuffs' | ||||||
``` | ||||||
|
||||||
And then execute: | ||||||
|
||||||
```bash | ||||||
bundle | ||||||
``` | ||||||
|
||||||
Or install it directly on the current system using: | ||||||
|
||||||
```bash | ||||||
gem install handcuffs | ||||||
``` | ||||||
|
||||||
|
||||||
## Usage | ||||||
|
||||||
### Configuration | ||||||
|
||||||
Create a handcuffs initializer and define the migration phases in the order in which they should be run. You should also define a default phase for pre-existing "untagged" migrations, or if you want the option to tag only custom phases. | ||||||
|
||||||
The most basic configuration is an array of phase names, and using the first one as the default: | ||||||
|
||||||
```ruby | ||||||
# config/initializers/handcuffs.rb | ||||||
|
||||||
Handcuffs.configure do |config| | ||||||
# pre_restart migrations will/must run before post_restart migrations | ||||||
config.phases = [:pre_restart, :post_restart] | ||||||
config.default_phase = :pre_restart | ||||||
end | ||||||
``` | ||||||
|
||||||
Then call `phase` from inside your migrations | ||||||
If you have more complex or asynchrous workflows, you can use an alternate hash notation that allows prerequisite stages to be specified explicitly: | ||||||
|
||||||
```ruby | ||||||
# db/migrate/20160318230933_add_on_sale_column.rb | ||||||
# config/initializers/handcuffs.rb | ||||||
|
||||||
class AddOnSaleColumn < ActiveRecord::Migration | ||||||
Handcuffs.configure do |config| | ||||||
config.phases = { | ||||||
# Prevent running post_restart migrations if there are outstanding | ||||||
# pre_restart migrations | ||||||
post_restart: [:pre_restart], | ||||||
# Require pre_restarts before data_migrations, but do not enforce ordering | ||||||
# between data_migrations and post_restarts | ||||||
data_migrations: [:pre_restart], | ||||||
# pre_restarts have no prerequisite phases | ||||||
pre_restart: [] | ||||||
} | ||||||
end | ||||||
``` | ||||||
|
||||||
The default phase order in this case is determined by [Tsort](https://github.com/ruby/tsort) (topilogical sort). In order to validate the configuration and expected phase order it is reccomended that you check the phase configuration after any changes using the rake task: | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Typos (my bad)
Suggested change
|
||||||
|
||||||
```ruby | ||||||
rake handcuffs:phase_order | ||||||
``` | ||||||
|
||||||
This will display the default order in which phases will be run and list the prerequisites of each phase. It will raise an error if there are any circular dependencies or if any prerequisite is not a valid phase name. | ||||||
|
||||||
### Tagging Migrations | ||||||
|
||||||
Once configured, you can assign each migration to one of the defined phases using the `phase` setter method: | ||||||
|
||||||
```ruby | ||||||
# db/migrate/20240318230933_add_on_sale_column.rb | ||||||
|
||||||
class AddOnSaleColumn < ActiveRecord::Migration[7.0] | ||||||
|
||||||
phase :pre_restart | ||||||
|
||||||
def up | ||||||
def change | ||||||
add_column :products, :on_sale, :boolean | ||||||
end | ||||||
|
||||||
def down | ||||||
remove_column :products, :on_sale | ||||||
end | ||||||
|
||||||
end | ||||||
``` | ||||||
|
||||||
```ruby | ||||||
# db/migrate/20160318230988_add_on_sale_index | ||||||
# db/migrate/20240318230988_add_on_sale_index | ||||||
|
||||||
class AddOnSaleIndex < ActiveRecord::Migration | ||||||
class AddOnSaleIndex < ActiveRecord::Migration[7.0] | ||||||
|
||||||
phase :post_restart | ||||||
|
||||||
def up | ||||||
def change | ||||||
add_index :products, :on_sale, algorithm: :concurrently | ||||||
end | ||||||
|
||||||
def down | ||||||
remove_index :products, :on_sale | ||||||
end | ||||||
|
||||||
end | ||||||
``` | ||||||
|
||||||
You can then run your migrations in phases using | ||||||
### Running Migrations In Phases | ||||||
|
||||||
After Handcuffs is configured and migrations are properly tagged, you can then run migrations in phases using the `handcuffs:migrate` rake task with the specific phase to be run: | ||||||
|
||||||
```bash | ||||||
rake 'handcuffs:migrate[pre_restart]' | ||||||
|
@@ -66,62 +121,44 @@ or | |||||
rake 'handcuffs:migrate[post_restart]' | ||||||
``` | ||||||
|
||||||
You can run all migrations using | ||||||
*Note:* If you run phases out of order, or attempt to run a phase before outstanding migrations with a prerequisite phase have been run, a `HandcuffsPhaseOutOfOrderError` will be raised. | ||||||
|
||||||
```bash | ||||||
rake 'handcuffs:migrate[all]' | ||||||
``` | ||||||
### Running All Migrations | ||||||
|
||||||
This differs from running `rake db:migrate` in that migrations will be run in the _order that the phases are defined in the handcuffs config_. | ||||||
In CI and local developement you may want to run all phases at one time. | ||||||
|
||||||
If you run a handcuffs rake task and any migration does not have a phase defined, an error will be raised before any migrations are run. To prevent this error, you can define a default phase for migrations that don't define one. | ||||||
Handcuffs offers a single command that will run all migrations in phases and in the configured order: | ||||||
|
||||||
```ruby | ||||||
# config/initializers/handcuffs.rb | ||||||
|
||||||
Handcuffs.configure do |config| | ||||||
config.phases = [:pre_restart, :post_restart] | ||||||
config.default_phase = :pre_restart | ||||||
end | ||||||
```bash | ||||||
rake 'handcuffs:migrate[all]' | ||||||
``` | ||||||
|
||||||
## Installation | ||||||
This differs from running `rake db:migrate` in that migrations will be run in batches corresponding to the _order that the phases are defined in the handcuffs config_. Again, you can use `rake handcuffs:phase_order` to preview the order ahead of time. | ||||||
|
||||||
Add this line to your application's Gemfile: | ||||||
Of course, you can always run `rake db:migrate` at any time to run all migrations using the Rails default ordering and without regard to Handcuffs phase if you wish. | ||||||
|
||||||
```ruby | ||||||
gem 'handcuffs' | ||||||
``` | ||||||
|
||||||
And then execute: | ||||||
|
||||||
```bash | ||||||
bundle | ||||||
``` | ||||||
## Contributing | ||||||
|
||||||
Or install it yourself as: | ||||||
Bug reports and pull requests are welcome on GitHub at <https://github.com/procore-oss/handcuffs>. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct. | ||||||
|
||||||
```bash | ||||||
gem install handcuffs | ||||||
``` | ||||||
|
||||||
## Running specs | ||||||
## Running Tests Locally | ||||||
lessthanjacob marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
|
||||||
The specs for handcuffs are in the dummy application at `/spec/dummy/spec`. The spec suite requires PostgreSQL. To run it you will have to set the environment variables `POSTGRES_DB_USERNAME` and `POSTGRES_DB_PASSWORD`. You can then run the suite using `rake spec` | ||||||
|
||||||
## Contributing | ||||||
|
||||||
Bug reports and pull requests are welcome on GitHub at <https://github.com/procore-oss/handcuffs>. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct. | ||||||
|
||||||
## License | ||||||
|
||||||
The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). | ||||||
|
||||||
|
||||||
## About Procore | ||||||
|
||||||
<img | ||||||
src="https://www.procore.com/images/procore_logo.png" | ||||||
alt="Procore Logo" | ||||||
src="https://raw.githubusercontent.com/procore-oss/.github/main/procorelightlogo.png" | ||||||
alt="Procore Open Source" | ||||||
width="250px" | ||||||
/> | ||||||
|
||||||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -10,15 +10,17 @@ def self.configure | |
end | ||
|
||
class Configurator | ||
attr_accessor :phases | ||
attr_reader :phases | ||
attr_accessor :default_phase | ||
|
||
def initialize | ||
@phases = [] | ||
@default_phase = nil | ||
end | ||
|
||
def phases=(phases) | ||
@phases = Phases.new(phases) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Although there was no format checking prior to this change, a simple array is hard to do incorrectly. But I wonder if it would make sense to evaluate the input now that we are providing the ability to define a graph via a hash. That is much more complex, and maybe better to flag issues at at configuration time rather than flag a configuration error when migrations are actually run. Two failure cases I could see happening are phases = { a: [:b, :c], b: [], c: :b }
ph = Phases.new(phases) # <== no errors here
# running migrations leads to:
ph.in_order
(irb):26:in `block in in_order': undefined method `each' for :b:Symbol (NoMethodError)
->(phase, &block) { @phases.fetch(phase).each(&block) } (2) accidentally defining a circular dependency (not likely in simple setups, but possible in more complex configurations) phases = { a: [:b, :c], b: [:c], c: [:a] }
ph = Phases.new(phases) # <== no errors here
# running migrations leads to:
ph.in_order
(irb):24:in `in_order': uninitialized constant Phases::TSort (NameError)
TSort.tsort(
^^^^^ |
||
end | ||
end | ||
|
||
end | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,12 +1,12 @@ | ||
# frozen_string_literal: true | ||
|
||
module Handcuffs | ||
# Extended by ActiveRecord::Migrator in order to track the current phase | ||
module Extensions | ||
|
||
attr_reader :handcuffs_phase | ||
attr_accessor :handcuffs_phase | ||
|
||
def phase(phase) | ||
@handcuffs_phase = phase | ||
end | ||
|
||
end | ||
end | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,19 @@ | ||
# frozen_string_literal: true | ||
|
||
module Handcuffs | ||
# PendingFilter is prepended to ActiveRecord::Migrator in the rake tasks | ||
# in order to check the current phase before it is run | ||
module PendingFilterExt | ||
def runnable | ||
attempted_phase = self.class.handcuffs_phase | ||
if @direction == :up | ||
Handcuffs::PhaseFilter.new(attempted_phase, @direction).filter(super) | ||
else | ||
phase_migrations = Handcuffs::PhaseFilter.new(attempted_phase, @direction).filter(migrations) | ||
runnable = phase_migrations[start..finish] | ||
runnable.pop if target | ||
runnable.find_all { |m| ran?(m) } | ||
end | ||
end | ||
end | ||
end |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,41 @@ | ||
# frozen_string_literal: true | ||
|
||
require 'tsort' | ||
|
||
module Handcuffs | ||
# Phases encapsulates the list of phases and any interdependencies | ||
class Phases | ||
def initialize(phases) | ||
@phases = case phases | ||
when Hash | ||
phases.each_with_object({}) do |phase, acc| | ||
acc[phase[0].to_sym] = Array(phase[1]).map(&:to_sym) | ||
end | ||
else | ||
# Assume each entry depends on all entries before it | ||
phases.map(&:to_sym).each_with_object({}) do |phase, acc| | ||
acc[phase] = phases.take_while { |defined_phase| defined_phase != phase } | ||
end | ||
end | ||
end | ||
|
||
def to_sentence | ||
@phases.keys.to_sentence | ||
end | ||
|
||
def include?(phase) | ||
@phases.include?(phase) | ||
end | ||
|
||
def in_order | ||
TSort.tsort( | ||
@phases.method(:each_key), | ||
->(phase, &block) { @phases.fetch(phase).each(&block) } | ||
) | ||
end | ||
|
||
def prereqs(attempted_phase) | ||
@phases.fetch(attempted_phase, []) | ||
end | ||
end | ||
end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nb: Even though this is the only additional configuration at the moment, it might worth an additional refactor here to make this more explicit:
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, this is sort of where I started, then pared it back a bit. But another thing we want at some point is phase aliases (s.t. we can gradually do simplifying renames), and at that point, you sort of need something like this. Happy to take a swing at adding that.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I like the explicitness, but the notation gets more complicated and messy, and potentially more intricate parsing and validation as well.
In order to make the current simple config more usable by shortening the feedback loop, I added a rake task that both validates and prints out what Handcuffs perceives to the be ordering and dependencies, so if there is any question in the end users mind they can all ways do a quick check and modify their configuration as needed.