From b052ed103bb28b18736bc684bbff34d98c8e8181 Mon Sep 17 00:00:00 2001 From: "Ben Sheldon [he/him]" Date: Wed, 12 Jan 2022 21:42:00 -0800 Subject: [PATCH] Start async adapters once `ActiveRecord` and `ActiveJob` have loaded, potentially before `Rails.application.initialized?` (#483) --- lib/good_job.rb | 2 ++ lib/good_job/adapter.rb | 2 +- lib/good_job/dependencies.rb | 50 ++++++++++++++++++++++++++++++++++++ lib/good_job/railtie.rb | 21 ++++++++++++++- 4 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 lib/good_job/dependencies.rb diff --git a/lib/good_job.rb b/lib/good_job.rb index 6237bcfee..bb3eabfa9 100644 --- a/lib/good_job.rb +++ b/lib/good_job.rb @@ -18,6 +18,8 @@ # # +GoodJob+ is the top-level namespace and exposes configuration attributes. module GoodJob + include GoodJob::Dependencies + DEFAULT_LOGGER = ActiveSupport::TaggedLogging.new(ActiveSupport::Logger.new($stdout)) # @!attribute [rw] active_record_parent_class diff --git a/lib/good_job/adapter.rb b/lib/good_job/adapter.rb index 40d1bd899..9d2625228 100644 --- a/lib/good_job/adapter.rb +++ b/lib/good_job/adapter.rb @@ -27,7 +27,7 @@ class Adapter # @param queues [String, nil] determines which queues to execute jobs from when +execution_mode+ is set to +:async+. See {file:README.md#optimize-queues-threads-and-processes} for more details on the format of this string. You can also set this with the environment variable +GOOD_JOB_QUEUES+. Defaults to +"*"+. # @param poll_interval [Integer, nil] sets the number of seconds between polls for jobs when +execution_mode+ is set to +:async+. You can also set this with the environment variable +GOOD_JOB_POLL_INTERVAL+. Defaults to +1+. # @param start_async_on_initialize [Boolean] whether to start the async scheduler when the adapter is initialized. - def initialize(execution_mode: nil, queues: nil, max_threads: nil, poll_interval: nil, start_async_on_initialize: Rails.application.initialized?) + def initialize(execution_mode: nil, queues: nil, max_threads: nil, poll_interval: nil, start_async_on_initialize: GoodJob.async_ready?) @configuration = GoodJob::Configuration.new( { execution_mode: execution_mode, diff --git a/lib/good_job/dependencies.rb b/lib/good_job/dependencies.rb new file mode 100644 index 000000000..b9ef20649 --- /dev/null +++ b/lib/good_job/dependencies.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module GoodJob # :nodoc: + # Extends GoodJob module to track Rails boot dependencies. + module Dependencies + extend ActiveSupport::Concern + + included do + # @!attribute [rw] _rails_after_initialize_hook_called + # @!scope class + # Whether Railtie.after_initialize has been called yet (default: +false+). + # This will be set on but before +Rails.application.initialize?+ is +true+. + # @return [Boolean] + mattr_accessor :_rails_after_initialize_hook_called, default: false + + # @!attribute [rw] _active_job_loaded + # @!scope class + # Whether ActiveJob has loaded (default: +false+). + # @return [Boolean] + mattr_accessor :_active_job_loaded, default: false + + # @!attribute [rw] _active_record_loaded + # @!scope class + # Whether ActiveRecord has loaded (default: +false+). + # @return [Boolean] + mattr_accessor :_active_record_loaded, default: false + end + + class_methods do + # Whether GoodJob's has been initialized as of the calling of +Railtie.after_initialize+. + # @return [Boolean] + def async_ready? + Rails.application.initialized? || ( + _rails_after_initialize_hook_called && + _active_job_loaded && + _active_record_loaded + ) + end + + def start_async_adapters + return unless async_ready? + + GoodJob::Adapter.instances + .select(&:execute_async?) + .reject(&:async_started?) + .each(&:start_async) + end + end + end +end diff --git a/lib/good_job/railtie.rb b/lib/good_job/railtie.rb index dcf672a59..859747dc0 100644 --- a/lib/good_job/railtie.rb +++ b/lib/good_job/railtie.rb @@ -34,8 +34,27 @@ class Railtie < ::Rails::Railtie end initializer "good_job.start_async" do + # This hooks into the hookable places during Rails boot, which is unfortunately not Rails.application.initialized? + # If an Adapter is initialized during boot, we want to want to start its async executors once the framework dependencies have loaded. + # When exactly that happens is out of our control because gems or application code may touch things earlier than expected. + # For example, as of Rails 6.1, if an ActiveRecord model is touched during boot, that triggers ActiveRecord to load, + # which touches DestroyAssociationAsyncJob, which loads ActiveJob, which may initialize a GoodJob::Adapter, all of which + # happens _before_ ActiveRecord finishes loading. GoodJob will deadlock if an async executor is started in the middle of + # ActiveRecord loading. + config.after_initialize do - GoodJob::Adapter.instances.each(&:start_async) + ActiveSupport.on_load(:active_record) do + GoodJob._active_record_loaded = true + GoodJob.start_async_adapters + end + + ActiveSupport.on_load(:active_job) do + GoodJob._active_job_loaded = true + GoodJob.start_async_adapters + end + + GoodJob._rails_after_initialize_hook_called = true + GoodJob.start_async_adapters end end end