Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ gem "dry-validation", "~> 1.10"
gem "globalid", "~> 1.0"

group :development, :test do
gem "activejob", "~> 7.0"
gem "opentelemetry-api"
gem "opentelemetry-sdk"
end
13 changes: 8 additions & 5 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -11,21 +11,24 @@ PATH
GEM
remote: https://rubygems.org/
specs:
activesupport (8.1.1)
activejob (7.2.3.1)
activesupport (= 7.2.3.1)
globalid (>= 0.3.6)
activesupport (7.2.3.1)
base64
benchmark (>= 0.3)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2)
json
logger (>= 1.4.2)
minitest (>= 5.1)
minitest (>= 5.1, < 6)
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
uri (>= 0.13.1)
ast (2.4.3)
base64 (0.3.0)
benchmark (0.5.0)
bigdecimal (3.2.3)
concurrent-ruby (1.3.5)
connection_pool (2.4.1)
Expand Down Expand Up @@ -179,14 +182,14 @@ GEM
unicode-display_width (3.2.0)
unicode-emoji (~> 4.1)
unicode-emoji (4.2.0)
uri (1.1.1)
zeitwerk (2.7.3)

PLATFORMS
arm64-darwin-24
ruby

DEPENDENCIES
activejob (~> 7.0)
debug (~> 1.8)
dry-validation (~> 1.10)
globalid (~> 1.0)
Expand Down
19 changes: 18 additions & 1 deletion lib/ruby_reactor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@
# sidekiq is optional, async features won't be available
end

# Load active_job if available (for the ActiveJob async adapter)
begin
require "active_job"
rescue LoadError
# active_job is optional, only needed when using the ActiveJob adapter
end

loader = Zeitwerk::Loader.for_gem
loader.inflector.inflect("api" => "API", "rspec" => "RSpec")
loader.setup
Expand Down Expand Up @@ -356,7 +363,17 @@ def self.reactor_storage_name(reactor_class)
def self.start_sweeper!
return unless configuration.sweeper_enabled

SidekiqWorkers::SweeperWorker.schedule_next
sweeper_job_class.schedule_next
end

# The sweeper job class living alongside the configured `async_router`
# (e.g. `Adapters::Sidekiq::Router` -> `Adapters::Sidekiq::SweeperWorker`),
# so the chain is kicked through whichever backend is configured instead of
# a hardcoded Sidekiq class.
def self.sweeper_job_class
router = configuration.async_router
namespace = Object.const_get(router.name.rpartition("::").first)
namespace.const_get(:SweeperWorker)
end
Comment on lines +373 to 377

# Run both recovery sweepers exactly once and return their counts. The
Expand Down
24 changes: 24 additions & 0 deletions lib/ruby_reactor/adapters/active_job/compat.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# frozen_string_literal: true

module RubyReactor
module Adapters
module ActiveJob
# `Sidekiq::Worker` gives every job class `.perform_async` / `.perform_in`
# for free; ActiveJob only has `.perform_later`. Extending this onto an
# ActiveJob class normalizes its enqueue API to the same two class
# methods, so `RubyReactor::Worker` and `RubyReactor::SweeperJob` can keep
# calling `self.class.perform_in(...)` unchanged regardless of backend.
# Both methods return the job id (a String), matching what Sidekiq's
# native `perform_async`/`perform_in` return.
module Compat
def perform_async(*args)
perform_later(*args).job_id
end

def perform_in(delay, *args)
set(wait: delay).perform_later(*args).job_id
end
end
end
end
end
19 changes: 19 additions & 0 deletions lib/ruby_reactor/adapters/active_job/map_collector_worker.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# frozen_string_literal: true

require "active_job"

module RubyReactor
module Adapters
module ActiveJob
class MapCollectorWorker < ::ActiveJob::Base
extend Compat

queue_as { RubyReactor.configuration.queue_name }

def perform(arguments)
RubyReactor::Map::Collector.perform(arguments)
end
end
end
end
end
19 changes: 19 additions & 0 deletions lib/ruby_reactor/adapters/active_job/map_element_worker.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# frozen_string_literal: true

require "active_job"

module RubyReactor
module Adapters
module ActiveJob
class MapElementWorker < ::ActiveJob::Base
extend Compat

queue_as { RubyReactor.configuration.queue_name }

def perform(arguments)
RubyReactor::Map::ElementExecutor.perform(arguments)
end
end
end
end
end
91 changes: 91 additions & 0 deletions lib/ruby_reactor/adapters/active_job/router.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# frozen_string_literal: true

module RubyReactor
module Adapters
module ActiveJob
class Router
# Identity-only payload: the worker rehydrates the live context from storage
# by (context_id, reactor_class_name). The caller already holds context_id, so
# there is no blob to deserialize here.
def self.perform_async(context_id, reactor_class_name = nil, intermediate_results: {})
job_id = RubyReactor::Adapters::ActiveJob::Worker.perform_async(context_id, reactor_class_name)
RubyReactor::AsyncResult.new(job_id: job_id, intermediate_results: intermediate_results,
execution_id: context_id)
end

def self.perform_in(delay, context_id, reactor_class_name = nil, intermediate_results: {})
job_id = RubyReactor::Adapters::ActiveJob::Worker.perform_in(delay, context_id, reactor_class_name)
RubyReactor::AsyncResult.new(job_id: job_id, intermediate_results: intermediate_results,
execution_id: context_id)
end

# rubocop:disable Metrics/ParameterLists
def self.perform_map_element_async(map_id:, element_id:, index:, serialized_inputs:, reactor_class_info:,
strict_ordering:, parent_context_id:, parent_reactor_class_name:,
step_name:, batch_size: nil, serialized_context: nil, fail_fast: nil)
job_id = RubyReactor::Adapters::ActiveJob::MapElementWorker.perform_async(
{
"map_id" => map_id,
"element_id" => element_id,
"index" => index,
"serialized_inputs" => serialized_inputs,
"reactor_class_info" => reactor_class_info,
"strict_ordering" => strict_ordering,
"parent_context_id" => parent_context_id,
"parent_reactor_class_name" => parent_reactor_class_name,
"step_name" => step_name,
"batch_size" => batch_size,
"serialized_context" => serialized_context,
"fail_fast" => fail_fast
}
)
RubyReactor::AsyncResult.new(job_id: job_id)
end

def self.perform_map_element_in(delay, map_id:, element_id:, index:, serialized_inputs:,
reactor_class_info:, strict_ordering:, parent_context_id:,
parent_reactor_class_name:, step_name:, batch_size: nil,
serialized_context: nil, fail_fast: nil)
job_id = RubyReactor::Adapters::ActiveJob::MapElementWorker.perform_in(
delay,
{
"map_id" => map_id,
"element_id" => element_id,
"index" => index,
"serialized_inputs" => serialized_inputs,
"reactor_class_info" => reactor_class_info,
"strict_ordering" => strict_ordering,
"parent_context_id" => parent_context_id,
"parent_reactor_class_name" => parent_reactor_class_name,
"step_name" => step_name,
"batch_size" => batch_size,
"serialized_context" => serialized_context,
"fail_fast" => fail_fast
}
)
# Return an AsyncResult so RetryManager#handle_async_retry recognises the
# element was successfully requeued and yields a RetryQueuedResult.
RubyReactor::AsyncResult.new(job_id: job_id)
end
# rubocop:enable Metrics/ParameterLists

# rubocop:disable Metrics/ParameterLists
def self.perform_map_collection_async(parent_context_id:, map_id:, parent_reactor_class_name:, step_name:,
strict_ordering:, timeout:)
job_id = RubyReactor::Adapters::ActiveJob::MapCollectorWorker.perform_async(
{
"parent_context_id" => parent_context_id,
"map_id" => map_id,
"parent_reactor_class_name" => parent_reactor_class_name,
"step_name" => step_name,
"strict_ordering" => strict_ordering,
"timeout" => timeout
}
)
RubyReactor::AsyncResult.new(job_id: job_id)
end
# rubocop:enable Metrics/ParameterLists
end
end
end
end
16 changes: 16 additions & 0 deletions lib/ruby_reactor/adapters/active_job/sweeper_worker.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# frozen_string_literal: true

require "active_job"

module RubyReactor
module Adapters
module ActiveJob
class SweeperWorker < ::ActiveJob::Base
extend Compat
include RubyReactor::SweeperJob

queue_as { RubyReactor.configuration.queue_name }
end
end
end
end
24 changes: 24 additions & 0 deletions lib/ruby_reactor/adapters/active_job/worker.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# frozen_string_literal: true

require "active_job"

module RubyReactor
module Adapters
module ActiveJob
# ActiveJob worker for executing RubyReactor reactors asynchronously.
# All resume/snooze/escalate logic lives in `RubyReactor::Worker` — this
# class only wires it to ActiveJob.
#
# Infra-failure retries only: reactor-specific contention/config errors
# are already rescued inside `RubyReactor::Worker#perform` (snoozed or
# escalated to `failed`) before they'd ever reach this `retry_on`.
class Worker < ::ActiveJob::Base
extend Compat
include RubyReactor::Worker

queue_as { RubyReactor.configuration.queue_name }
retry_on StandardError, attempts: RubyReactor.configuration.job_retry_count
end
end
end
end
15 changes: 15 additions & 0 deletions lib/ruby_reactor/adapters/sidekiq/map_collector_worker.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# frozen_string_literal: true

module RubyReactor
module Adapters
module Sidekiq
class MapCollectorWorker
include ::Sidekiq::Worker

def perform(arguments)
RubyReactor::Map::Collector.perform(arguments)
end
end
end
end
end
15 changes: 15 additions & 0 deletions lib/ruby_reactor/adapters/sidekiq/map_element_worker.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# frozen_string_literal: true

module RubyReactor
module Adapters
module Sidekiq
class MapElementWorker
include ::Sidekiq::Worker

def perform(arguments)
RubyReactor::Map::ElementExecutor.perform(arguments)
end
end
end
end
end
Loading
Loading