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

Parallel testing #31900

Merged
merged 1 commit into from Feb 16, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions activerecord/lib/active_record.rb
Expand Up @@ -163,6 +163,7 @@ module Tasks
"active_record/tasks/postgresql_database_tasks"
end

autoload :TestDatabases, "active_record/test_databases"
autoload :TestFixtures, "active_record/fixtures"

def self.eager_load!
Expand Down
5 changes: 3 additions & 2 deletions activerecord/lib/active_record/fixtures.rb
Expand Up @@ -874,6 +874,7 @@ def after_teardown # :nodoc:
class_attribute :use_instantiated_fixtures, default: false # true, false, or :no_instances
class_attribute :pre_loaded_fixtures, default: false
class_attribute :config, default: ActiveRecord::Base
class_attribute :lock_threads, default: true
end

module ClassMethods
Expand Down Expand Up @@ -973,7 +974,7 @@ def setup_fixtures(config = ActiveRecord::Base)
@fixture_connections = enlist_fixture_connections
@fixture_connections.each do |connection|
connection.begin_transaction joinable: false
connection.pool.lock_thread = true
connection.pool.lock_thread = true if lock_threads
end

# When connections are established in the future, begin a transaction too
Expand All @@ -989,7 +990,7 @@ def setup_fixtures(config = ActiveRecord::Base)

if connection && !@fixture_connections.include?(connection)
connection.begin_transaction joinable: false
connection.pool.lock_thread = true
connection.pool.lock_thread = true if lock_threads
@fixture_connections << connection
end
end
Expand Down
38 changes: 38 additions & 0 deletions activerecord/lib/active_record/test_databases.rb
@@ -0,0 +1,38 @@
# frozen_string_literal: true

require "active_support/testing/parallelization"

module ActiveRecord
module TestDatabases # :nodoc:
ActiveSupport::Testing::Parallelization.after_fork_hook do |i|
create_and_migrate(i, spec_name: Rails.env)
end

ActiveSupport::Testing::Parallelization.run_cleanup_hook do |i|
drop(i, spec_name: Rails.env)
end

def self.create_and_migrate(i, spec_name:)
old, ENV["VERBOSE"] = ENV["VERBOSE"], "false"

connection_spec = ActiveRecord::Base.configurations[spec_name]

connection_spec["database"] += "-#{i}"
Copy link
Contributor

Choose a reason for hiding this comment

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

What about underscore "_#{i}" to keep file based databases using underscores in file names?

Copy link
Member Author

Choose a reason for hiding this comment

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

They're temporary databases, unless it's going to break file based dbs I don't think underscore vs dash is a big deal.

ActiveRecord::Tasks::DatabaseTasks.create(connection_spec)
ActiveRecord::Base.establish_connection(connection_spec)
ActiveRecord::Tasks::DatabaseTasks.migrate
Copy link
Member Author

Choose a reason for hiding this comment

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

I plan on replacing this with a structure load or a straight copy of the database later on but currently structure load doesn't work with multiple databases so sticking with migrate for now.

ensure
ActiveRecord::Base.establish_connection(ActiveRecord::Base.configurations[Rails.env])
ENV["VERBOSE"] = old
end

def self.drop(i, spec_name:)
old, ENV["VERBOSE"] = ENV["VERBOSE"], "false"
connection_spec = ActiveRecord::Base.configurations[spec_name]

ActiveRecord::Tasks::DatabaseTasks.drop(connection_spec)
ensure
ENV["VERBOSE"] = old
end
end
end
5 changes: 5 additions & 0 deletions activesupport/CHANGELOG.md
@@ -1,2 +1,7 @@
* Adds parallel testing to Rails

Parallelize your test suite with forked processes or threads.

*Eileen M. Uchitelle*, *Aaron Patterson*

Please check [5-2-stable](https://github.com/rails/rails/blob/5-2-stable/activesupport/CHANGELOG.md) for previous changes.
86 changes: 86 additions & 0 deletions activesupport/lib/active_support/test_case.rb
Expand Up @@ -11,6 +11,7 @@
require "active_support/testing/constant_lookup"
require "active_support/testing/time_helpers"
require "active_support/testing/file_fixtures"
require "active_support/testing/parallelization"

module ActiveSupport
class TestCase < ::Minitest::Test
Expand Down Expand Up @@ -39,6 +40,91 @@ def test_order=(new_order)
def test_order
ActiveSupport.test_order ||= :random
end

# Parallelizes the test suite.
#
# Takes a `workers` argument that controls how many times the process
# is forked. For each process a new database will be created suffixed
# with the worker number.
#
# test-database-0
# test-database-1
#
# If `ENV["PARALLEL_WORKERS"]` is set the workers argument will be ignored
# and the environment variable will be used instead. This is useful for CI
# environments, or other environments where you may need more workers than
# you do for local testing.
#
# If the number of workers is set to `1` or fewer, the tests will not be
# parallelized.
#
# The default parallelization method is to fork processes. If you'd like to
# use threads instead you can pass `with: :threads` to the `parallelize`
# method. Note the threaded parallelization does not create multiple
# database and will not work with system tests at this time.
#
# parallelize(workers: 2, with: :threads)
#
# The threaded parallelization uses Minitest's parallel exector directly.
# The processes paralleliztion uses a Ruby Drb server.
def parallelize(workers: 2, with: :processes)
workers = ENV["PARALLEL_WORKERS"].to_i if ENV["PARALLEL_WORKERS"]

return if workers <= 1

executor = case with
when :processes
Testing::Parallelization.new(workers)
when :threads
Minitest::Parallel::Executor.new(workers)
else
raise ArgumentError, "#{with} is not a supported parallelization exectutor."

Choose a reason for hiding this comment

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

I believe this exectutor is a typo.

Copy link
Contributor

Choose a reason for hiding this comment

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

It was fixed by a4e226f

end

self.lock_threads = false if defined?(self.lock_threads) && with == :threads

Minitest.parallel_executor = executor

parallelize_me!
end

# Set up hook for parallel testing. This can be used if you have multiple
# databases or any behavior that needs to be run after the process is forked
# but before the tests run.
#
# Note: this feature is not available with the threaded parallelization.
#
# In your +test_helper.rb+ add the following:
#
# class ActiveSupport::TestCase
# parallelize_setup do
# # create databases
# end
# end
def parallelize_setup(&block)
ActiveSupport::Testing::Parallelization.after_fork_hook do |worker|
yield worker
end
end

# Clean up hook for parallel testing. This can be used to drop databases
# if your app uses multiple write/read databases or other clean up before
# the tests finish. This runs before the forked process is closed.
#
# Note: this feature is not available with the threaded parallelization.
#
# In your +test_helper.rb+ add the following:
#
# class ActiveSupport::TestCase
# parallelize_teardown do
# # drop databases
# end
# end
def parallelize_teardown(&block)
ActiveSupport::Testing::Parallelization.run_cleanup_hook do |worker|
yield worker
end
end
end

alias_method :method_name, :name
Expand Down
102 changes: 102 additions & 0 deletions activesupport/lib/active_support/testing/parallelization.rb
@@ -0,0 +1,102 @@
# frozen_string_literal: true

require "drb"
require "drb/unix"

module ActiveSupport
module Testing
class Parallelization # :nodoc:
class Server
include DRb::DRbUndumped

def initialize
@queue = Queue.new
end

def record(reporter, result)
reporter.synchronize do
reporter.record(result)
end
end

def <<(o)
Copy link
Contributor

Choose a reason for hiding this comment

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

What not use delegator for << and pop methods?

Copy link
Contributor

Choose a reason for hiding this comment

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

I'm wondering what o is — object? Is there some name we could give it that'd help describe what it's intended to be?

@queue << o
end

def pop; @queue.pop; end
end

@after_fork_hooks = []

def self.after_fork_hook(&blk)
@after_fork_hooks << blk
end

def self.after_fork_hooks
@after_fork_hooks
end

@run_cleanup_hooks = []

def self.run_cleanup_hook(&blk)
@run_cleanup_hooks << blk
end

def self.run_cleanup_hooks
@run_cleanup_hooks
end

def initialize(queue_size)
@queue_size = queue_size
@queue = Server.new
@pool = []

@url = DRb.start_service("drbunix:", @queue).uri
end

def after_fork(worker)
self.class.after_fork_hooks.each do |cb|
cb.call(worker)
end
end

def run_cleanup(worker)
self.class.run_cleanup_hooks.each do |cb|
cb.call(worker)
end
end

def start
@pool = @queue_size.times.map do |worker|
fork do
DRb.stop_service

after_fork(worker)

queue = DRbObject.new_with_uri(@url)

while job = queue.pop
klass = job[0]
method = job[1]
reporter = job[2]
result = Minitest.run_one_method(klass, method)

queue.record(reporter, result)
end

run_cleanup(worker)
end
end
end

def <<(work)
@queue << work
end

def shutdown
@queue_size.times { @queue << nil }
@pool.each { |pid| Process.waitpid pid }
end
end
end
end
83 changes: 83 additions & 0 deletions guides/source/testing.md
Expand Up @@ -462,6 +462,89 @@ Rails options:
-c, --[no-]color Enable color in the output
```

Parallel Testing
----------------

Parallel testing allows you to parallelize your test suite. While forking processes is the
default method, threading is supported as well. Running tests in parallel reduces the time it
takes your entire test suite to run.

## Parallel testing with processes

The default parallelization method is to fork processes using Ruby's DRb system. The processes
are forked based on the number of workers provided. The default is 2, but can be changed by the
number passed to the parallelize method. Active Record automatically handles creating and
migrating a new database for each worker to use.

To enable parallelization add the following to your `test_helper.rb`:

```
class ActiveSupport::TestCase
parallelize(workers: 2)
end
```

The number of workers passed is the number of times the process will be forked. You may want to
parallelize your local test suite differently from your CI, so an environment variable is provided
to be able to easily change the number of workers a test run should use:

```
PARALLEL_WORKERS=15 bin/rails test
```

When parallelizing tests, Active Record automatically handles creating and migrating a database for each
process. The databases will be suffixed with the number corresponding to the worker. For example, if you
have 2 workers the tests will create `test-database-0` and `test-database-1` respectively.

If the number of workers passed is 1 or fewer the processes will not be forked and the tests will not
be parallelized and the tests will use the original `test-database` database.

Two hooks are provided, one runs when the process is forked, and one runs before the processes are closed.
These can be useful if your app uses multiple databases or perform other tasks that depend on the number of
workers.

The `parallelize_setup` method is called right after the processes are forked. The `parallelize_teardown` metod
is called right before the processes are closed.

```
class ActiveSupport::TestCase
parallelize_setup do |worker|
# setup databases
end

parallelize_teardown do |worker|
# cleanup database
end

parallelize(workers: 2)
end
```

These methods are not needed or available when using parallel testing with threads.

## Parallel testing with threads

If you prefer using threads or are using JRuby, a threaded parallelization option is provided. The threaded
parallelizer is backed by Minitest's `Parallel::Executor`.

To change the parallelization method to use threads over forks put the following in your `test_helper.rb`

```
class ActiveSupport::TestCase
parallelize(workers: 2, with: :threads)
end
```

Rails applications generated from JRuby will automatically include the `with: :threads` option.

The number of workers passed to `parallelize` determines the number of threads the tests will use. You may
want to parallelize your local test suite differently from your CI, so an environment variable is provided
to be able to easily change the number of workers a test run should use:

```
PARALLEL_WORKERS=15 bin/rails test
```

The Test Database
-----------------

Expand Down
Expand Up @@ -3,6 +3,13 @@ require_relative '../config/environment'
require 'rails/test_help'

class ActiveSupport::TestCase
# Run tests in parallel with specified workers
<% if defined?(JRUBY_VERSION) -%>
parallelize(workers: 2, with: :threads)
<%- else -%>
parallelize(workers: 2)
<% end -%>

<% unless options[:skip_active_record] -%>
# Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order.
fixtures :all
Expand Down
1 change: 1 addition & 0 deletions railties/lib/rails/test_help.rb
Expand Up @@ -22,6 +22,7 @@

module ActiveSupport
class TestCase
include ActiveRecord::TestDatabases
include ActiveRecord::TestFixtures
self.fixture_path = "#{Rails.root}/test/fixtures/"
self.file_fixture_path = fixture_path + "files"
Expand Down