Skip to content

Initial implementation of ActiveJob AsyncAdapter. #21257

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

Merged
merged 1 commit into from
Aug 25, 2015
Merged

Initial implementation of ActiveJob AsyncAdapter. #21257

merged 1 commit into from
Aug 25, 2015

Conversation

jdantonio
Copy link
Contributor

Now that activesupport has a runtime dependency on concurrent-ruby, we can begin taking advantage of those tools in more ways. This PR creates a simple asynchronous ActiveJob adapter that posts jobs to a concurrent-ruby thread pool. Within the context of ActiveJob it provides functionality comparable to sucker_punch. Rails 5 users will now be able to create simple asynchronous jobs without installing additional gems simply by setting the new adapter:

# config/application.rb
module YourApp
  class Application < Rails::Application
    config.active_job.queue_adapter = :async
  end
end

A simple benchmark script which compares enqueue performance vs. sucker_punch can be found here. Performance is comparable on both Ruby 2.2.2 and JRuby 9000.

If this PR is accepted I can add more features such as job prioritization and per-queue thread pools.

@jdantonio
Copy link
Contributor Author

NOTE: I think my benchmarks are uninformative. Since I used the test helpers to setup ActiveJob, it looks like both adapters are performing synchronously, not asynchronously. I will gather better benchmarks as soon as I figure out how to setup the test properly.

@jdantonio
Copy link
Contributor Author

The benchmark script and associated data have been updated. They now represent asynchronous behavior. Performance is roughly the same for AsyncAdapter and SuckerPunchAdapter.

@rafaelfranca
Copy link
Member

Thank you for the pull request but I don't think we should maintain a queue
system inside Rails. There are a lot of good implementations out there that
I think we just end up with a poor implementation inside the framework, to
not say the maintenance overhead that it will give us.

On Sun, Aug 16, 2015, 15:46 jdantonio notifications@github.com wrote:

The benchmark script and associated data have been updated. They now
represent asynchronous behavior. Performance is roughly the same for
AsyncAdapter and SuckerPunchAdapter.


Reply to this email directly or view it on GitHub
#21257 (comment).

@rafaelfranca
Copy link
Member

@dhh @tenderlove @matthewd @senny @chancancode what are your thoughts about a default async adapter in Rails itself?

@dhh
Copy link
Member

dhh commented Aug 16, 2015

I actually would like to see a default async queue for ActiveJob, mostly for testing. No, you shouldn't use that in production unless you don't care about losing jobs if a process crashes, but it would be nice for testing, so you wouldn't have to install Sucker Punch.

@rafaelfranca
Copy link
Member

If that is the goal 👍 too. It should be explicit that it should not be used on production.

@dhh
Copy link
Member

dhh commented Aug 16, 2015

Agree. Or only used in production for things with no criticality.

On Sun, Aug 16, 2015 at 5:21 PM, Rafael Mendonça França <
notifications@github.com> wrote:

If that is the goal [image: 👍] too. It should be explicit that it
should not be used on production.


Reply to this email directly or view it on GitHub
#21257 (comment).

@jdantonio
Copy link
Contributor Author

I'll begin working on the full feature set tomorrow (Monday). Scheduled tasks (#set(wait: 1.week)) won't survive a restart, but that should be fine for testing. I'll update the guide to be abundantly clear that the :async adapter is for testing only. I'll update this PR when it is feature-complete.

@dhh
Copy link
Member

dhh commented Aug 17, 2015

Thanks! Yeah, would be really great to have scheduled tasks work for
testing. Sucker Punch doesn't support those. I had that issue with some
tasks that had a 15s delay that I couldn't test.

On Sun, Aug 16, 2015 at 11:23 PM, jdantonio notifications@github.com
wrote:

I'll begin working on the full feature set tomorrow (Monday). Scheduled
tasks (#set(wait: 1.week)) won't survive a restart, but that should be
fine for testing. I'll update the guide to be abundantly clear that the
:async adapter is for testing only. I'll update this PR when it is
feature-complete.


Reply to this email directly or view it on GitHub
#21257 (comment).

@jdantonio
Copy link
Contributor Author

@dhh @rafaelfranca I'm sorry it took me a few days to get back to this, but it's ready now. I've added job scheduling and support for custom queues. I've also updated the documentation to briefly explain why this adapter is for dev/test and not prod.

@cristianbica
Copy link
Member

Really nice. I do have 2 suggestions:

  • Follow the model of all the adapters: have a different class (ex: ActiveJob::AsyncJob) that handles the queueing, execution, queue creating and the configurations (ex: executor) and the adapter should just call enqueue / enqueue_at on the AsyncJob class.
  • it seems a bit complicated to have to create the queues upfront. I would rather see the async class create the queue if not exists

@jdantonio
Copy link
Contributor Author

@carllerche I'll start work on both of those changes this evening.

@jdantonio
Copy link
Contributor Author

@cristianbica This update implements both of your suggestions. Now that ActiveJob::AsyncJob is a separate class I would like to add a set of tests that test it directly, but I'd like to get your feedback on this update before I write those. (The adapter tests still pass.)

@kaspth
Copy link
Contributor

kaspth commented Aug 20, 2015

executor might be close to the concurrent-ruby terminology, but it feels very removed from Active Job. It would be great to find a term closer to home.

In fact we might not need to expose that executor information at all. As the adapter seems to have a lot of configuration for something that's mostly for testing. What need is there to change the pool size in testing as long as we're using a minimum of 2?

}.freeze

QUEUES = ThreadSafe::Cache.new do |hash, queue_name|
hash[queue_name] = case queue_name
Copy link
Contributor

Choose a reason for hiding this comment

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

Lets do:

queue_name = queue_name.to_sym

hash[queue_name] = \
  if queue_name == :default
    ActiveJob::AsyncJob.default_executor
  else
    ActiveJob::AsyncJob.create_executor
  end

@jdantonio
Copy link
Contributor Author

@kaspth I was definitely overthinking things. Running the tests requires that the job runner perform synchronously. The original version was a proof-of-concept that didn't support queues or scheduled tasks, so I simply injected an ImmediateExecutor when running the tests. When I started adding new features I lost sight of the original intent and made things more complicated than necessary.

This update has the same features yet is simpler:

  • ActiveJob::AsyncJob has a set_test_mode! class method that the test helper calls. It is undocumented and irreversible. I expect that it will only be called from the test helper in this repo.
  • All queues, including the default queue, are created with the same configuration.
  • Queues are automatically created the first time a job is post to the queue.
  • Although it's a narrow use case, queues can still be manually created and configured. The only option in this case is to pass a manually created thread pool as an argument. I think we can assume that anyone choosing to manually configure a queue is also capable of manually configuring the thread pool.

This update also includes a set of unit tests for the AsyncJob class.

class QueueCreationError < ArgumentError; end

class << self
# Force all jobs to run synchronously when testing the activejob gem.
Copy link
Contributor

Choose a reason for hiding this comment

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

It's Active Job 😁

Copy link
Contributor

Choose a reason for hiding this comment

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

Also why do we need to run async adapter jobs synchronously in our own tests? Won't that hurt ourselves down the line?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

My apologies regarding the gem name. I've fixed that.

I must have misunderstood what was happening in the test helpers. Some of the test helpers set a "test" backend or mode. When I run the tests without a test mode one of the serialization tests fails. I'll try to figure out what's happening there.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Correction--all the adapter tests fail when there is no test mode.

test/adapters/delayed_job.rb:

Delayed::Worker.delay_jobs = false
Delayed::Worker.backend    = :test

test/adapters/qu.rb:

require 'qu-immediate'

test/adapters/resque.rb:

Resque.inline = true

Copy link
Contributor

Choose a reason for hiding this comment

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

No worries about the name 😁

I'm not sure I follow you. Perhaps we're talking about different things?

My confusion is how the async adapter implements a test mode by not doing what it says on the tin. I can understand if we need the adapter to run synchronously for its own tests, but shouldn't we also have integration tests that mimic how an actual user tests with the adapter?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Oh. I think I see what you are saying. If I follow, you are suggesting another set of tests that actually post background jobs then verify that the jobs actually run in the background. Is that correct? That's not a problem. I'll add a set of tests for that.

The latest update is a rebase against the latest Rails master and also incorporates the other suggestions.

Copy link
Contributor

Choose a reason for hiding this comment

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

Exactly that 👍

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@kaspth I apologize if I'm missing something, but it appears that the file test/integration/queuing_test.rb currently tests the asynchronous behavior of all adapters. It posts jobs with perform_later then uses timers (such as wait_for_jobs_to_finish_for(2.seconds)) to verify that the jobs post. It looks like the adapter tests put all the adapters in synchronous mode, the existing integration tests put the adapters in asynchronous mode, and the Rakefile controls the environment appropriately for each set of tests. However, I didn't notice this earlier so I have not setup the necessary test helper for making the integration tests work with the Async Job. The integration tests (which do not run with rake test) are failing. I'll fix that.

@kaspth
Copy link
Contributor

kaspth commented Aug 23, 2015

This just keeps getting better 👏

@jdantonio
Copy link
Contributor Author

@kaspth The test cases in test/integration/queuing_test.rb are precisely the type of test you suggested. When I created an appropriate test helper and ran those tests I discovered a localization bug. The latest version has the following changes:

  • Fix the aforementioned locale (serialization) bug
  • Follows the pattern of the other adapters and serializes the job within the adapter
  • Follows the pattern of the other adapters and explicitly passes the queue name from the adapter
  • Adds a test job and AsyncJob unit test case for queue_as
  • Adds a set_normal_mode! method for consistent unit test setup/teardown.

All of the tests unit tests (rake test:async) and integration tests (rake test:integration:async) pass.

require 'jobs/queue_as_job'

class AsyncJobTest < ActiveSupport::TestCase

Copy link
Contributor

Choose a reason for hiding this comment

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

✂️ this line

# Raises +QueueCreationError+ when the queue already exists.
def create_queue(name, thread_pool)
raise QueueCreationError.new('queue already exists') if QUEUES.key? name
# possible race condition here but the use case is very narrow
Copy link
Contributor

Choose a reason for hiding this comment

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

If the use case is so very narrow should we really add this method? Generally we'd like to keep as closed off a public interface as possible.

@kaspth
Copy link
Contributor

kaspth commented Aug 24, 2015

@robin850 can you check the documentation?

@cristianbica what do you think about this? I'm not too familiar with Active Job's tests, so how are these looking?

# pool directly then call the +create_queue+ method passing the thread
# pool as the second parameter:
#
# thread_pool = Concurrent::FixedThreadPool.new(10, max_queue: 100, fallback_polcy: :caller_runs)
Copy link
Member

Choose a reason for hiding this comment

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

There's a missing "i" in fallback_policy.

@robin850
Copy link
Member

Great job @jdantonio! This is looking good! 👏

def create_queue(name, thread_pool)
raise QueueCreationError.new('queue already exists') if QUEUES.key? name
# possible race condition here but the use case is very narrow
QUEUES[name] = thread_pool
Copy link
Contributor

Choose a reason for hiding this comment

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

Why not synchronize this method?

@jdantonio
Copy link
Contributor Author

@kaspth @robin850 Updated based on latest feedback and rebased against latest master.

kaspth added a commit that referenced this pull request Aug 25, 2015
Initial implementation of ActiveJob AsyncAdapter.
@kaspth kaspth merged commit c5a88e5 into rails:master Aug 25, 2015
@kaspth
Copy link
Contributor

kaspth commented Aug 25, 2015

Thanks @jdantonio, this is great 👍

@jdantonio
Copy link
Contributor Author

@kaspth Thank you very much for your feedback! This PR is much better because of your help.

@kaspth
Copy link
Contributor

kaspth commented Aug 25, 2015

❤️

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

8 participants