Skip to content
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

Merged
merged 7 commits into from
Jan 23, 2025
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 18 additions & 5 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## 2.0.0
## 2.1.0 : 2025-01-24 [diff](https://github.com/procore-oss/handcuffs/compare/v2.0.0..main)

### Added

- Ability to specify prerequisite phases in a non-linear order

### Changed

- (internal) bumped rspec-rails gem version in development dependencies
- (internal) bumped minimum gem versions in test Rails app
- (internal) update github workflow


## 2.0.0 : 2024-02-20 [diff](https://github.com/procore-oss/handcuffs/compare/v1.4.1..v2.0.0)

### Removed

- **BREAKING CHANGE**: Removed support for Ruby < 2.7, Rails < 6.1, PostgreSQL < 12.

### Added

Expand All @@ -24,7 +41,3 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Updated Bundler to 2.4.22.
- Added Appraisal for dummy app testing.
- Moved repo to procore-oss

### Removed

- BREAKING CHANGE: Removed support for Ruby < 2.7, Rails < 6.1, PostgreSQL < 12.
143 changes: 90 additions & 53 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Comment on lines +58 to +60
Copy link
Contributor

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:

Handcuffs.configure do |config|
  phases << Phase.new(:pre_restart)
  phases << Phase.new(:post_restart, prerequisites: [:pre_restart]
end

# Or obsfuscate the initialization.
Handcuffs.configure do |config|
  config.phases = [
    :pre_restart,
    { post_restart: { prerequisites: [:pre_restart]}
  ]
end

Copy link
Contributor

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.

Copy link
Contributor

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.

# 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:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typos (my bad)

Suggested change
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:
The default phase order in this case is determined by [Tsort](https://github.com/ruby/tsort) (topological sort). In order to validate the configuration and expected phase order it is recommended that you check the phase configuration after any changes using the rake task:


```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]'
Expand All @@ -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"
/>

Expand Down
6 changes: 4 additions & 2 deletions lib/handcuffs/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Copy link
Contributor

Choose a reason for hiding this comment

The 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.
If nothing else, the configuration class seems like the right place to provide meaningful feedback for invalid configurations.

Two failure cases I could see happening are
(1) Not defining dependencies as an array

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

8 changes: 4 additions & 4 deletions lib/handcuffs/extensions.rb
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

19 changes: 19 additions & 0 deletions lib/handcuffs/pending_filter_ext.rb
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
4 changes: 2 additions & 2 deletions lib/handcuffs/phase_filter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ def runnable_for_phase(by_phase, defined_phases)
end

def check_order_up!(by_phase, defined_phases)
defined_phases.take_while { |defined_phase| defined_phase != attempted_phase }
defined_phases.prereqs(attempted_phase)
.detect { |defined_phase| by_phase.key?(defined_phase) }
.tap do |defined_phase|
raise HandcuffsPhaseOutOfOrderError.new(defined_phase, attempted_phase) if defined_phase
Expand All @@ -58,7 +58,7 @@ def check_order_down!(by_phase, defined_phases)
end

def all_phases_by_configuration_order(by_phase, defined_phases)
defined_phases.reduce([]) do |acc, phase|
defined_phases.in_order.reduce([]) do |acc, phase|
acc | Array(by_phase[phase])
end.map { |mh| mh[:proxy] }
end
Expand Down
41 changes: 41 additions & 0 deletions lib/handcuffs/phases.rb
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
Loading
Loading