ruby_reactor 0.5.4 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.claude/skills/speckit-agent-context-update/SKILL.md +32 -0
- data/.claude/skills/speckit-analyze/SKILL.md +262 -0
- data/.claude/skills/speckit-checklist/SKILL.md +374 -0
- data/.claude/skills/speckit-clarify/SKILL.md +286 -0
- data/.claude/skills/speckit-constitution/SKILL.md +157 -0
- data/.claude/skills/speckit-converge/SKILL.md +277 -0
- data/.claude/skills/speckit-implement/SKILL.md +224 -0
- data/.claude/skills/speckit-plan/SKILL.md +171 -0
- data/.claude/skills/speckit-specify/SKILL.md +346 -0
- data/.claude/skills/speckit-tasks/SKILL.md +215 -0
- data/.claude/skills/speckit-taskstoissues/SKILL.md +110 -0
- data/.release-please-manifest.json +1 -1
- data/.specify/extensions/.registry +19 -0
- data/.specify/extensions/agent-context/README.md +66 -0
- data/.specify/extensions/agent-context/agent-context-config.yml +5 -0
- data/.specify/extensions/agent-context/commands/speckit.agent-context.update.md +27 -0
- data/.specify/extensions/agent-context/extension.yml +34 -0
- data/.specify/extensions/agent-context/scripts/bash/update-agent-context.sh +282 -0
- data/.specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1 +353 -0
- data/.specify/extensions.yml +23 -0
- data/.specify/feature.json +3 -0
- data/.specify/init-options.json +9 -0
- data/.specify/integration.json +15 -0
- data/.specify/integrations/claude.manifest.json +17 -0
- data/.specify/integrations/speckit.manifest.json +17 -0
- data/.specify/memory/constitution.md +134 -0
- data/.specify/scripts/bash/check-prerequisites.sh +189 -0
- data/.specify/scripts/bash/common.sh +619 -0
- data/.specify/scripts/bash/create-new-feature.sh +299 -0
- data/.specify/scripts/bash/setup-plan.sh +84 -0
- data/.specify/scripts/bash/setup-tasks.sh +91 -0
- data/.specify/templates/checklist-template.md +40 -0
- data/.specify/templates/constitution-template.md +50 -0
- data/.specify/templates/plan-template.md +113 -0
- data/.specify/templates/spec-template.md +131 -0
- data/.specify/templates/tasks-template.md +252 -0
- data/.specify/workflows/speckit/workflow.yml +77 -0
- data/.specify/workflows/workflow-registry.json +13 -0
- data/CHANGELOG.md +7 -0
- data/README.md +51 -24
- data/lib/ruby_reactor/adapters/active_job/compat.rb +24 -0
- data/lib/ruby_reactor/adapters/active_job/map_collector_worker.rb +19 -0
- data/lib/ruby_reactor/adapters/active_job/map_element_worker.rb +19 -0
- data/lib/ruby_reactor/adapters/active_job/router.rb +91 -0
- data/lib/ruby_reactor/adapters/active_job/sweeper_worker.rb +16 -0
- data/lib/ruby_reactor/adapters/active_job/worker.rb +24 -0
- data/lib/ruby_reactor/adapters/sidekiq/map_collector_worker.rb +15 -0
- data/lib/ruby_reactor/adapters/sidekiq/map_element_worker.rb +15 -0
- data/lib/ruby_reactor/adapters/sidekiq/router.rb +91 -0
- data/lib/ruby_reactor/adapters/sidekiq/sweeper_worker.rb +19 -0
- data/lib/ruby_reactor/adapters/sidekiq/worker.rb +25 -0
- data/lib/ruby_reactor/configuration.rb +24 -4
- data/lib/ruby_reactor/map/element_executor.rb +1 -1
- data/lib/ruby_reactor/rspec/active_job_helpers.rb +52 -0
- data/lib/ruby_reactor/rspec/async_test_helpers.rb +41 -0
- data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +3 -3
- data/lib/ruby_reactor/rspec/test_subject.rb +11 -7
- data/lib/ruby_reactor/rspec.rb +4 -0
- data/lib/ruby_reactor/sweeper_job.rb +70 -0
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor/worker.rb +226 -0
- data/lib/ruby_reactor.rb +40 -1
- data/specs/active_job.md +259 -0
- metadata +54 -6
- data/lib/ruby_reactor/sidekiq_adapter.rb +0 -87
- data/lib/ruby_reactor/sidekiq_workers/map_collector_worker.rb +0 -13
- data/lib/ruby_reactor/sidekiq_workers/map_element_worker.rb +0 -13
- data/lib/ruby_reactor/sidekiq_workers/sweeper_worker.rb +0 -73
- data/lib/ruby_reactor/sidekiq_workers/worker.rb +0 -222
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_job"
|
|
4
|
+
|
|
5
|
+
module RubyReactor
|
|
6
|
+
module Adapters
|
|
7
|
+
module ActiveJob
|
|
8
|
+
class MapCollectorWorker < ::ActiveJob::Base
|
|
9
|
+
extend Compat
|
|
10
|
+
|
|
11
|
+
queue_as { RubyReactor.configuration.queue_name }
|
|
12
|
+
|
|
13
|
+
def perform(arguments)
|
|
14
|
+
RubyReactor::Map::Collector.perform(arguments)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_job"
|
|
4
|
+
|
|
5
|
+
module RubyReactor
|
|
6
|
+
module Adapters
|
|
7
|
+
module ActiveJob
|
|
8
|
+
class MapElementWorker < ::ActiveJob::Base
|
|
9
|
+
extend Compat
|
|
10
|
+
|
|
11
|
+
queue_as { RubyReactor.configuration.queue_name }
|
|
12
|
+
|
|
13
|
+
def perform(arguments)
|
|
14
|
+
RubyReactor::Map::ElementExecutor.perform(arguments)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
module Adapters
|
|
5
|
+
module ActiveJob
|
|
6
|
+
class Router
|
|
7
|
+
# Identity-only payload: the worker rehydrates the live context from storage
|
|
8
|
+
# by (context_id, reactor_class_name). The caller already holds context_id, so
|
|
9
|
+
# there is no blob to deserialize here.
|
|
10
|
+
def self.perform_async(context_id, reactor_class_name = nil, intermediate_results: {})
|
|
11
|
+
job_id = RubyReactor::Adapters::ActiveJob::Worker.perform_async(context_id, reactor_class_name)
|
|
12
|
+
RubyReactor::AsyncResult.new(job_id: job_id, intermediate_results: intermediate_results,
|
|
13
|
+
execution_id: context_id)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.perform_in(delay, context_id, reactor_class_name = nil, intermediate_results: {})
|
|
17
|
+
job_id = RubyReactor::Adapters::ActiveJob::Worker.perform_in(delay, context_id, reactor_class_name)
|
|
18
|
+
RubyReactor::AsyncResult.new(job_id: job_id, intermediate_results: intermediate_results,
|
|
19
|
+
execution_id: context_id)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# rubocop:disable Metrics/ParameterLists
|
|
23
|
+
def self.perform_map_element_async(map_id:, element_id:, index:, serialized_inputs:, reactor_class_info:,
|
|
24
|
+
strict_ordering:, parent_context_id:, parent_reactor_class_name:,
|
|
25
|
+
step_name:, batch_size: nil, serialized_context: nil, fail_fast: nil)
|
|
26
|
+
job_id = RubyReactor::Adapters::ActiveJob::MapElementWorker.perform_async(
|
|
27
|
+
{
|
|
28
|
+
"map_id" => map_id,
|
|
29
|
+
"element_id" => element_id,
|
|
30
|
+
"index" => index,
|
|
31
|
+
"serialized_inputs" => serialized_inputs,
|
|
32
|
+
"reactor_class_info" => reactor_class_info,
|
|
33
|
+
"strict_ordering" => strict_ordering,
|
|
34
|
+
"parent_context_id" => parent_context_id,
|
|
35
|
+
"parent_reactor_class_name" => parent_reactor_class_name,
|
|
36
|
+
"step_name" => step_name,
|
|
37
|
+
"batch_size" => batch_size,
|
|
38
|
+
"serialized_context" => serialized_context,
|
|
39
|
+
"fail_fast" => fail_fast
|
|
40
|
+
}
|
|
41
|
+
)
|
|
42
|
+
RubyReactor::AsyncResult.new(job_id: job_id)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.perform_map_element_in(delay, map_id:, element_id:, index:, serialized_inputs:,
|
|
46
|
+
reactor_class_info:, strict_ordering:, parent_context_id:,
|
|
47
|
+
parent_reactor_class_name:, step_name:, batch_size: nil,
|
|
48
|
+
serialized_context: nil, fail_fast: nil)
|
|
49
|
+
job_id = RubyReactor::Adapters::ActiveJob::MapElementWorker.perform_in(
|
|
50
|
+
delay,
|
|
51
|
+
{
|
|
52
|
+
"map_id" => map_id,
|
|
53
|
+
"element_id" => element_id,
|
|
54
|
+
"index" => index,
|
|
55
|
+
"serialized_inputs" => serialized_inputs,
|
|
56
|
+
"reactor_class_info" => reactor_class_info,
|
|
57
|
+
"strict_ordering" => strict_ordering,
|
|
58
|
+
"parent_context_id" => parent_context_id,
|
|
59
|
+
"parent_reactor_class_name" => parent_reactor_class_name,
|
|
60
|
+
"step_name" => step_name,
|
|
61
|
+
"batch_size" => batch_size,
|
|
62
|
+
"serialized_context" => serialized_context,
|
|
63
|
+
"fail_fast" => fail_fast
|
|
64
|
+
}
|
|
65
|
+
)
|
|
66
|
+
# Return an AsyncResult so RetryManager#handle_async_retry recognises the
|
|
67
|
+
# element was successfully requeued and yields a RetryQueuedResult.
|
|
68
|
+
RubyReactor::AsyncResult.new(job_id: job_id)
|
|
69
|
+
end
|
|
70
|
+
# rubocop:enable Metrics/ParameterLists
|
|
71
|
+
|
|
72
|
+
# rubocop:disable Metrics/ParameterLists
|
|
73
|
+
def self.perform_map_collection_async(parent_context_id:, map_id:, parent_reactor_class_name:, step_name:,
|
|
74
|
+
strict_ordering:, timeout:)
|
|
75
|
+
job_id = RubyReactor::Adapters::ActiveJob::MapCollectorWorker.perform_async(
|
|
76
|
+
{
|
|
77
|
+
"parent_context_id" => parent_context_id,
|
|
78
|
+
"map_id" => map_id,
|
|
79
|
+
"parent_reactor_class_name" => parent_reactor_class_name,
|
|
80
|
+
"step_name" => step_name,
|
|
81
|
+
"strict_ordering" => strict_ordering,
|
|
82
|
+
"timeout" => timeout
|
|
83
|
+
}
|
|
84
|
+
)
|
|
85
|
+
RubyReactor::AsyncResult.new(job_id: job_id)
|
|
86
|
+
end
|
|
87
|
+
# rubocop:enable Metrics/ParameterLists
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_job"
|
|
4
|
+
|
|
5
|
+
module RubyReactor
|
|
6
|
+
module Adapters
|
|
7
|
+
module ActiveJob
|
|
8
|
+
class SweeperWorker < ::ActiveJob::Base
|
|
9
|
+
extend Compat
|
|
10
|
+
include RubyReactor::SweeperJob
|
|
11
|
+
|
|
12
|
+
queue_as { RubyReactor.configuration.queue_name }
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_job"
|
|
4
|
+
|
|
5
|
+
module RubyReactor
|
|
6
|
+
module Adapters
|
|
7
|
+
module ActiveJob
|
|
8
|
+
# ActiveJob worker for executing RubyReactor reactors asynchronously.
|
|
9
|
+
# All resume/snooze/escalate logic lives in `RubyReactor::Worker` — this
|
|
10
|
+
# class only wires it to ActiveJob.
|
|
11
|
+
#
|
|
12
|
+
# Infra-failure retries only: reactor-specific contention/config errors
|
|
13
|
+
# are already rescued inside `RubyReactor::Worker#perform` (snoozed or
|
|
14
|
+
# escalated to `failed`) before they'd ever reach this `retry_on`.
|
|
15
|
+
class Worker < ::ActiveJob::Base
|
|
16
|
+
extend Compat
|
|
17
|
+
include RubyReactor::Worker
|
|
18
|
+
|
|
19
|
+
queue_as { RubyReactor.configuration.queue_name }
|
|
20
|
+
retry_on StandardError, attempts: RubyReactor.configuration.job_retry_count
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
module Adapters
|
|
5
|
+
module Sidekiq
|
|
6
|
+
class MapCollectorWorker
|
|
7
|
+
include ::Sidekiq::Worker
|
|
8
|
+
|
|
9
|
+
def perform(arguments)
|
|
10
|
+
RubyReactor::Map::Collector.perform(arguments)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
module Adapters
|
|
5
|
+
module Sidekiq
|
|
6
|
+
class MapElementWorker
|
|
7
|
+
include ::Sidekiq::Worker
|
|
8
|
+
|
|
9
|
+
def perform(arguments)
|
|
10
|
+
RubyReactor::Map::ElementExecutor.perform(arguments)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
module Adapters
|
|
5
|
+
module Sidekiq
|
|
6
|
+
class Router
|
|
7
|
+
# Identity-only payload: the worker rehydrates the live context from storage
|
|
8
|
+
# by (context_id, reactor_class_name). The caller already holds context_id, so
|
|
9
|
+
# there is no blob to deserialize here.
|
|
10
|
+
def self.perform_async(context_id, reactor_class_name = nil, intermediate_results: {})
|
|
11
|
+
job_id = RubyReactor::Adapters::Sidekiq::Worker.perform_async(context_id, reactor_class_name)
|
|
12
|
+
RubyReactor::AsyncResult.new(job_id: job_id, intermediate_results: intermediate_results,
|
|
13
|
+
execution_id: context_id)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.perform_in(delay, context_id, reactor_class_name = nil, intermediate_results: {})
|
|
17
|
+
job_id = RubyReactor::Adapters::Sidekiq::Worker.perform_in(delay, context_id, reactor_class_name)
|
|
18
|
+
RubyReactor::AsyncResult.new(job_id: job_id, intermediate_results: intermediate_results,
|
|
19
|
+
execution_id: context_id)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# rubocop:disable Metrics/ParameterLists
|
|
23
|
+
def self.perform_map_element_async(map_id:, element_id:, index:, serialized_inputs:, reactor_class_info:,
|
|
24
|
+
strict_ordering:, parent_context_id:, parent_reactor_class_name:,
|
|
25
|
+
step_name:, batch_size: nil, serialized_context: nil, fail_fast: nil)
|
|
26
|
+
job_id = RubyReactor::Adapters::Sidekiq::MapElementWorker.perform_async(
|
|
27
|
+
{
|
|
28
|
+
"map_id" => map_id,
|
|
29
|
+
"element_id" => element_id,
|
|
30
|
+
"index" => index,
|
|
31
|
+
"serialized_inputs" => serialized_inputs,
|
|
32
|
+
"reactor_class_info" => reactor_class_info,
|
|
33
|
+
"strict_ordering" => strict_ordering,
|
|
34
|
+
"parent_context_id" => parent_context_id,
|
|
35
|
+
"parent_reactor_class_name" => parent_reactor_class_name,
|
|
36
|
+
"step_name" => step_name,
|
|
37
|
+
"batch_size" => batch_size,
|
|
38
|
+
"serialized_context" => serialized_context,
|
|
39
|
+
"fail_fast" => fail_fast
|
|
40
|
+
}
|
|
41
|
+
)
|
|
42
|
+
RubyReactor::AsyncResult.new(job_id: job_id)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.perform_map_element_in(delay, map_id:, element_id:, index:, serialized_inputs:,
|
|
46
|
+
reactor_class_info:, strict_ordering:, parent_context_id:,
|
|
47
|
+
parent_reactor_class_name:, step_name:, batch_size: nil,
|
|
48
|
+
serialized_context: nil, fail_fast: nil)
|
|
49
|
+
job_id = RubyReactor::Adapters::Sidekiq::MapElementWorker.perform_in(
|
|
50
|
+
delay,
|
|
51
|
+
{
|
|
52
|
+
"map_id" => map_id,
|
|
53
|
+
"element_id" => element_id,
|
|
54
|
+
"index" => index,
|
|
55
|
+
"serialized_inputs" => serialized_inputs,
|
|
56
|
+
"reactor_class_info" => reactor_class_info,
|
|
57
|
+
"strict_ordering" => strict_ordering,
|
|
58
|
+
"parent_context_id" => parent_context_id,
|
|
59
|
+
"parent_reactor_class_name" => parent_reactor_class_name,
|
|
60
|
+
"step_name" => step_name,
|
|
61
|
+
"batch_size" => batch_size,
|
|
62
|
+
"serialized_context" => serialized_context,
|
|
63
|
+
"fail_fast" => fail_fast
|
|
64
|
+
}
|
|
65
|
+
)
|
|
66
|
+
# Return an AsyncResult so RetryManager#handle_async_retry recognises the
|
|
67
|
+
# element was successfully requeued and yields a RetryQueuedResult.
|
|
68
|
+
RubyReactor::AsyncResult.new(job_id: job_id)
|
|
69
|
+
end
|
|
70
|
+
# rubocop:enable Metrics/ParameterLists
|
|
71
|
+
|
|
72
|
+
# rubocop:disable Metrics/ParameterLists
|
|
73
|
+
def self.perform_map_collection_async(parent_context_id:, map_id:, parent_reactor_class_name:, step_name:,
|
|
74
|
+
strict_ordering:, timeout:)
|
|
75
|
+
job_id = RubyReactor::Adapters::Sidekiq::MapCollectorWorker.perform_async(
|
|
76
|
+
{
|
|
77
|
+
"parent_context_id" => parent_context_id,
|
|
78
|
+
"map_id" => map_id,
|
|
79
|
+
"parent_reactor_class_name" => parent_reactor_class_name,
|
|
80
|
+
"step_name" => step_name,
|
|
81
|
+
"strict_ordering" => strict_ordering,
|
|
82
|
+
"timeout" => timeout
|
|
83
|
+
}
|
|
84
|
+
)
|
|
85
|
+
RubyReactor::AsyncResult.new(job_id: job_id)
|
|
86
|
+
end
|
|
87
|
+
# rubocop:enable Metrics/ParameterLists
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "sidekiq"
|
|
4
|
+
|
|
5
|
+
module RubyReactor
|
|
6
|
+
module Adapters
|
|
7
|
+
module Sidekiq
|
|
8
|
+
class SweeperWorker
|
|
9
|
+
include ::Sidekiq::Worker
|
|
10
|
+
include RubyReactor::SweeperJob
|
|
11
|
+
|
|
12
|
+
# retry: false — the sweep is idempotent and self-rescheduling, so a failed
|
|
13
|
+
# tick must not pile up Sidekiq retries; the next tick (or a super_fetch
|
|
14
|
+
# recovery) re-runs it anyway.
|
|
15
|
+
sidekiq_options retry: false, queue: RubyReactor.configuration.queue_name
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "sidekiq"
|
|
4
|
+
|
|
5
|
+
module RubyReactor
|
|
6
|
+
module Adapters
|
|
7
|
+
module Sidekiq
|
|
8
|
+
# Sidekiq worker for executing RubyReactor reactors asynchronously
|
|
9
|
+
# with non-blocking retry capabilities. All resume/snooze/escalate logic
|
|
10
|
+
# lives in `RubyReactor::Worker` — this class only wires it to Sidekiq.
|
|
11
|
+
class Worker
|
|
12
|
+
include ::Sidekiq::Worker
|
|
13
|
+
include RubyReactor::Worker
|
|
14
|
+
|
|
15
|
+
# Enable Sidekiq retries for infrastructure failures only
|
|
16
|
+
sidekiq_options retry: RubyReactor.configuration.job_retry_count, dead: false,
|
|
17
|
+
queue: RubyReactor.configuration.queue_name
|
|
18
|
+
|
|
19
|
+
sidekiq_retries_exhausted do |_, _exception|
|
|
20
|
+
# Handle infrastructure failures (network, Redis, etc.)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -7,13 +7,23 @@ module RubyReactor
|
|
|
7
7
|
class Configuration
|
|
8
8
|
include Singleton
|
|
9
9
|
|
|
10
|
-
attr_writer :
|
|
10
|
+
attr_writer :queue_name, :job_retry_count, :logger, :async_router,
|
|
11
11
|
:lock_snooze_base_delay, :lock_snooze_jitter, :lock_snooze_max_attempts,
|
|
12
12
|
:middlewares, :context_ttl, :context_lock_ttl, :checkpoint_min_interval,
|
|
13
13
|
:sweeper_enabled, :sweeper_interval, :sweeper_limit
|
|
14
14
|
|
|
15
|
+
def queue_name
|
|
16
|
+
@queue_name ||= :default
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Deprecated alias for `queue_name` — kept so existing Sidekiq-only configs
|
|
20
|
+
# don't break.
|
|
15
21
|
def sidekiq_queue
|
|
16
|
-
|
|
22
|
+
queue_name
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def sidekiq_queue=(value)
|
|
26
|
+
self.queue_name = value
|
|
17
27
|
end
|
|
18
28
|
|
|
19
29
|
# Retention TTL (seconds) for a stored reactor context. Storage is
|
|
@@ -79,8 +89,18 @@ module RubyReactor
|
|
|
79
89
|
@context_lock_ttl ||= 60
|
|
80
90
|
end
|
|
81
91
|
|
|
92
|
+
def job_retry_count
|
|
93
|
+
@job_retry_count ||= 3
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Deprecated alias for `job_retry_count` — kept so existing Sidekiq-only
|
|
97
|
+
# configs don't break.
|
|
82
98
|
def sidekiq_retry_count
|
|
83
|
-
|
|
99
|
+
job_retry_count
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def sidekiq_retry_count=(value)
|
|
103
|
+
self.job_retry_count = value
|
|
84
104
|
end
|
|
85
105
|
|
|
86
106
|
# Base seconds the Sidekiq worker waits before re-checking a contended lock.
|
|
@@ -104,7 +124,7 @@ module RubyReactor
|
|
|
104
124
|
end
|
|
105
125
|
|
|
106
126
|
def async_router
|
|
107
|
-
@async_router ||= RubyReactor::
|
|
127
|
+
@async_router ||= RubyReactor::Adapters::Sidekiq::Router
|
|
108
128
|
end
|
|
109
129
|
|
|
110
130
|
def storage
|
|
@@ -51,7 +51,7 @@ module RubyReactor
|
|
|
51
51
|
# The element already runs inside its own background worker, so any async
|
|
52
52
|
# steps (and async retries) must execute inline here rather than handing
|
|
53
53
|
# off to a detached Worker that would escape map result/counter tracking.
|
|
54
|
-
# This mirrors
|
|
54
|
+
# This mirrors RubyReactor::Worker, which sets the same flag.
|
|
55
55
|
context.inline_async_execution = true
|
|
56
56
|
|
|
57
57
|
storage = RubyReactor.configuration.storage_adapter
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
module RSpec
|
|
5
|
+
# ActiveJob counterpart to `SidekiqHelpers`, used internally by
|
|
6
|
+
# `AsyncTestHelpers` to drain the ActiveJob `:test` queue adapter. Only
|
|
7
|
+
# the static methods are needed (`AsyncTestHelpers` dispatches to them);
|
|
8
|
+
# `Rails::ActiveJob::TestHelper` already covers most spec-author-facing
|
|
9
|
+
# assertions, so this module is not auto-included into examples.
|
|
10
|
+
module ActiveJobHelpers
|
|
11
|
+
PendingJob = Struct.new(:job_class, :raw) do
|
|
12
|
+
def perform!
|
|
13
|
+
::ActiveJob::Base.queue_adapter.enqueued_jobs.delete(raw)
|
|
14
|
+
job_class.new(*raw[:args]).perform_now
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def args
|
|
18
|
+
raw[:args]
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.test_adapter?
|
|
23
|
+
defined?(::ActiveJob::Base) &&
|
|
24
|
+
::ActiveJob::Base.queue_adapter.is_a?(::ActiveJob::QueueAdapters::TestAdapter)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Drain every queued job in the ActiveJob `:test` adapter until the
|
|
28
|
+
# queue is empty. Recursive — handles jobs that re-enqueue themselves
|
|
29
|
+
# (e.g. ordered_lock snoozes) and job chains that queue additional jobs.
|
|
30
|
+
def self.drain_async_jobs(max_iterations: 100)
|
|
31
|
+
return unless test_adapter?
|
|
32
|
+
|
|
33
|
+
queue = ::ActiveJob::Base.queue_adapter.enqueued_jobs
|
|
34
|
+
|
|
35
|
+
max_iterations.times do
|
|
36
|
+
break if queue.empty?
|
|
37
|
+
|
|
38
|
+
queue.dup.each do |job|
|
|
39
|
+
queue.delete(job)
|
|
40
|
+
job[:job].new(*job[:args]).perform_now
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.pending_async_jobs
|
|
46
|
+
return [] unless test_adapter?
|
|
47
|
+
|
|
48
|
+
::ActiveJob::Base.queue_adapter.enqueued_jobs.map { |raw| PendingJob.new(raw[:job], raw) }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
module RSpec
|
|
5
|
+
# Single entry point `TestSubject` uses to detect and drain whichever
|
|
6
|
+
# async testing framework — Sidekiq::Testing fake mode or ActiveJob's
|
|
7
|
+
# `:test` queue adapter — is currently active, so the job-processing gate
|
|
8
|
+
# isn't hardcoded to one background processor.
|
|
9
|
+
module AsyncTestHelpers
|
|
10
|
+
def self.active?
|
|
11
|
+
sidekiq_testing? || active_job_testing?
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.sidekiq_testing?
|
|
15
|
+
defined?(::Sidekiq::Testing) && ::Sidekiq::Testing.fake?
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.active_job_testing?
|
|
19
|
+
ActiveJobHelpers.test_adapter?
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.drain_async_jobs(max_iterations: 100)
|
|
23
|
+
if sidekiq_testing?
|
|
24
|
+
SidekiqHelpers.drain_async_jobs(max_iterations: max_iterations)
|
|
25
|
+
elsif active_job_testing?
|
|
26
|
+
ActiveJobHelpers.drain_async_jobs(max_iterations: max_iterations)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.pending_async_jobs
|
|
31
|
+
if sidekiq_testing?
|
|
32
|
+
SidekiqHelpers.pending_async_jobs
|
|
33
|
+
elsif active_job_testing?
|
|
34
|
+
ActiveJobHelpers.pending_async_jobs
|
|
35
|
+
else
|
|
36
|
+
[]
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -36,9 +36,9 @@ module RubyReactor
|
|
|
36
36
|
|
|
37
37
|
def self.worker_classes
|
|
38
38
|
@worker_classes ||= [
|
|
39
|
-
RubyReactor::
|
|
40
|
-
RubyReactor::
|
|
41
|
-
RubyReactor::
|
|
39
|
+
RubyReactor::Adapters::Sidekiq::Worker,
|
|
40
|
+
RubyReactor::Adapters::Sidekiq::MapElementWorker,
|
|
41
|
+
RubyReactor::Adapters::Sidekiq::MapCollectorWorker
|
|
42
42
|
]
|
|
43
43
|
end
|
|
44
44
|
|
|
@@ -191,9 +191,9 @@ module RubyReactor
|
|
|
191
191
|
end
|
|
192
192
|
|
|
193
193
|
@run_result = nil
|
|
194
|
-
if @process_jobs &&
|
|
195
|
-
# Ensure
|
|
196
|
-
allow(RubyReactor.configuration).to receive(:async_router).and_return(RubyReactor::
|
|
194
|
+
if @process_jobs && AsyncTestHelpers.sidekiq_testing?
|
|
195
|
+
# Ensure the Sidekiq router is used to capture jobs in fake mode
|
|
196
|
+
allow(RubyReactor.configuration).to receive(:async_router).and_return(RubyReactor::Adapters::Sidekiq::Router)
|
|
197
197
|
|
|
198
198
|
# Avoid nesting error which happens in Sidekiq 7+ if a mode is already set
|
|
199
199
|
begin
|
|
@@ -203,6 +203,10 @@ module RubyReactor
|
|
|
203
203
|
rescue Sidekiq::Testing::TestModeAlreadySetError
|
|
204
204
|
@run_result = execution_class.run(@inputs)
|
|
205
205
|
end
|
|
206
|
+
elsif @process_jobs && AsyncTestHelpers.active_job_testing?
|
|
207
|
+
# Ensure the ActiveJob router is used to capture jobs in the :test adapter
|
|
208
|
+
allow(RubyReactor.configuration).to receive(:async_router).and_return(RubyReactor::Adapters::ActiveJob::Router)
|
|
209
|
+
@run_result = execution_class.run(@inputs)
|
|
206
210
|
else
|
|
207
211
|
@run_result = execution_class.run(@inputs)
|
|
208
212
|
end
|
|
@@ -250,7 +254,7 @@ module RubyReactor
|
|
|
250
254
|
skipped_result(ctx)
|
|
251
255
|
when "running"
|
|
252
256
|
# Try to determine if it is truly running or if we just missed the completion
|
|
253
|
-
if @process_jobs &&
|
|
257
|
+
if @process_jobs && AsyncTestHelpers.active?
|
|
254
258
|
# Force one more check
|
|
255
259
|
process_pending_jobs
|
|
256
260
|
# Reload status
|
|
@@ -357,7 +361,7 @@ module RubyReactor
|
|
|
357
361
|
@reactor_instance.continue(payload: payload, step_name: step_name)
|
|
358
362
|
|
|
359
363
|
# Process any pending async jobs
|
|
360
|
-
process_pending_jobs if @process_jobs &&
|
|
364
|
+
process_pending_jobs if @process_jobs && AsyncTestHelpers.active?
|
|
361
365
|
|
|
362
366
|
# Reload the reactor instance to get updated state
|
|
363
367
|
@reactor_instance = @reactor_class.find(@reactor_instance.context.context_id)
|
|
@@ -430,9 +434,9 @@ module RubyReactor
|
|
|
430
434
|
private
|
|
431
435
|
|
|
432
436
|
def process_pending_jobs
|
|
433
|
-
return unless
|
|
437
|
+
return unless AsyncTestHelpers.active?
|
|
434
438
|
|
|
435
|
-
|
|
439
|
+
AsyncTestHelpers.drain_async_jobs
|
|
436
440
|
@reactor_instance = @reactor_class.find(@reactor_instance.context.context_id)
|
|
437
441
|
end
|
|
438
442
|
|
data/lib/ruby_reactor/rspec.rb
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
require_relative "rspec/helpers"
|
|
4
4
|
require_relative "rspec/matchers"
|
|
5
5
|
require_relative "rspec/sidekiq_helpers"
|
|
6
|
+
require_relative "rspec/active_job_helpers"
|
|
7
|
+
require_relative "rspec/async_test_helpers"
|
|
6
8
|
require_relative "rspec/storage_reset"
|
|
7
9
|
require_relative "rspec/test_subject"
|
|
8
10
|
|
|
@@ -44,6 +46,8 @@ module RubyReactor
|
|
|
44
46
|
::Sidekiq::Worker.clear_all
|
|
45
47
|
end
|
|
46
48
|
|
|
49
|
+
::ActiveJob::Base.queue_adapter.enqueued_jobs.clear if AsyncTestHelpers.active_job_testing?
|
|
50
|
+
|
|
47
51
|
adapter = ::RubyReactor.configuration.storage_adapter
|
|
48
52
|
adapter.reset! if adapter.respond_to?(:reset!)
|
|
49
53
|
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module RubyReactor
|
|
6
|
+
# Self-rescheduling recovery tick, shared by every queueing backend's
|
|
7
|
+
# sweeper job class. Each run sweeps both the top-level reactor sweeper and
|
|
8
|
+
# the map sweeper, then schedules the next tick — a perpetual chain the
|
|
9
|
+
# host kicks once via `RubyReactor.start_sweeper!`.
|
|
10
|
+
#
|
|
11
|
+
# super_fetch safety. Sidekiq Enterprise `super_fetch` reliably re-runs a job
|
|
12
|
+
# whose worker died mid-execution. For a self-rescheduling chain that is a
|
|
13
|
+
# hazard: a tick can crash AFTER enqueuing its successor but BEFORE acking, so
|
|
14
|
+
# super_fetch recovers the crashed tick *alongside* the successor it already
|
|
15
|
+
# scheduled — the chain forks and then doubles every interval. We therefore do
|
|
16
|
+
# NOT rely on "exactly one job exists". The next tick is claimed by a
|
|
17
|
+
# per-time-window lock: every duplicate computes the SAME target window and
|
|
18
|
+
# only one wins the claim, so recovered/duplicated ticks collapse back to a
|
|
19
|
+
# single chain. The claim lock is never released — it simply expires — so no
|
|
20
|
+
# delete can race two duplicates into both winning.
|
|
21
|
+
module SweeperJob
|
|
22
|
+
def self.included(base)
|
|
23
|
+
base.extend(ClassMethods)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def perform
|
|
27
|
+
config = RubyReactor.configuration
|
|
28
|
+
return unless config.sweeper_enabled
|
|
29
|
+
|
|
30
|
+
run_sweeps(config)
|
|
31
|
+
ensure
|
|
32
|
+
# Always chain forward (unless disabled), even after an error above, so a
|
|
33
|
+
# single bad sweep can't kill recovery. The window lock keeps this from
|
|
34
|
+
# forking under super_fetch.
|
|
35
|
+
self.class.schedule_next if RubyReactor.configuration.sweeper_enabled
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def run_sweeps(config)
|
|
39
|
+
RubyReactor::Sweeper.run_once(limit: config.sweeper_limit)
|
|
40
|
+
RubyReactor::Map::Sweeper.run_once(limit: config.sweeper_limit)
|
|
41
|
+
rescue StandardError => e
|
|
42
|
+
config.logger.error("RubyReactor sweeper sweep failed: #{e.class}: #{e.message}")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
module ClassMethods
|
|
46
|
+
# Enqueue the next tick for the upcoming time window, claiming that window
|
|
47
|
+
# so concurrent/duplicate/recovered ticks produce exactly one successor.
|
|
48
|
+
# Idempotent: also safe to call from `start_sweeper!` on every process boot.
|
|
49
|
+
def schedule_next
|
|
50
|
+
interval = RubyReactor.configuration.sweeper_interval
|
|
51
|
+
window = (Time.now.to_i / interval) + 1
|
|
52
|
+
|
|
53
|
+
lock = RubyReactor::Lock.new(
|
|
54
|
+
"sweeper:window:#{window}",
|
|
55
|
+
owner: SecureRandom.uuid,
|
|
56
|
+
ttl: interval * 2, # outlive the window; expires on its own (never released)
|
|
57
|
+
wait: 0,
|
|
58
|
+
auto_extend: false
|
|
59
|
+
)
|
|
60
|
+
lock.acquire # raises AcquisitionError if this window is already claimed
|
|
61
|
+
|
|
62
|
+
delay = (window * interval) - Time.now.to_i
|
|
63
|
+
perform_in([delay, 1].max)
|
|
64
|
+
rescue RubyReactor::Lock::AcquisitionError
|
|
65
|
+
# Another tick already scheduled this window — collapse the duplicate.
|
|
66
|
+
nil
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
data/lib/ruby_reactor/version.rb
CHANGED