ruby_reactor 0.3.0 → 0.3.2
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/README.md +145 -9
- data/documentation/README.md +20 -8
- data/documentation/async_reactors.md +46 -34
- data/documentation/core_concepts.md +75 -61
- data/documentation/examples/inventory_management.md +2 -3
- data/documentation/examples/order_processing.md +92 -77
- data/documentation/examples/payment_processing.md +28 -117
- data/documentation/getting_started.md +112 -94
- data/documentation/interrupts.md +9 -7
- data/documentation/locks_and_semaphores.md +459 -0
- data/documentation/retry_configuration.md +19 -14
- data/documentation/testing.md +994 -0
- data/lib/ruby_reactor/configuration.rb +19 -2
- data/lib/ruby_reactor/context.rb +13 -5
- data/lib/ruby_reactor/context_serializer.rb +55 -4
- data/lib/ruby_reactor/dsl/lockable.rb +130 -0
- data/lib/ruby_reactor/dsl/reactor.rb +3 -2
- data/lib/ruby_reactor/error/step_failure_error.rb +5 -2
- data/lib/ruby_reactor/executor/result_handler.rb +27 -2
- data/lib/ruby_reactor/executor/retry_manager.rb +15 -7
- data/lib/ruby_reactor/executor/step_executor.rb +29 -99
- data/lib/ruby_reactor/executor.rb +148 -15
- data/lib/ruby_reactor/lock.rb +92 -0
- data/lib/ruby_reactor/map/collector.rb +16 -15
- data/lib/ruby_reactor/map/element_executor.rb +90 -104
- data/lib/ruby_reactor/map/execution.rb +2 -1
- data/lib/ruby_reactor/map/helpers.rb +2 -1
- data/lib/ruby_reactor/map/result_enumerator.rb +1 -1
- data/lib/ruby_reactor/period.rb +67 -0
- data/lib/ruby_reactor/rate_limit.rb +74 -0
- data/lib/ruby_reactor/reactor.rb +175 -16
- data/lib/ruby_reactor/rspec/helpers.rb +17 -0
- data/lib/ruby_reactor/rspec/matchers.rb +423 -0
- data/lib/ruby_reactor/rspec/step_executor_patch.rb +85 -0
- data/lib/ruby_reactor/rspec/test_subject.rb +625 -0
- data/lib/ruby_reactor/rspec.rb +18 -0
- data/lib/ruby_reactor/semaphore.rb +58 -0
- data/lib/ruby_reactor/{async_router.rb → sidekiq_adapter.rb} +10 -5
- data/lib/ruby_reactor/sidekiq_workers/worker.rb +69 -9
- data/lib/ruby_reactor/step/compose_step.rb +0 -1
- data/lib/ruby_reactor/step/map_step.rb +11 -18
- data/lib/ruby_reactor/storage/redis_adapter.rb +2 -0
- data/lib/ruby_reactor/storage/redis_locking.rb +251 -0
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor/web/api.rb +32 -24
- data/lib/ruby_reactor.rb +119 -10
- metadata +16 -3
|
@@ -7,7 +7,8 @@ module RubyReactor
|
|
|
7
7
|
class Configuration
|
|
8
8
|
include Singleton
|
|
9
9
|
|
|
10
|
-
attr_writer :sidekiq_queue, :sidekiq_retry_count, :logger, :async_router
|
|
10
|
+
attr_writer :sidekiq_queue, :sidekiq_retry_count, :logger, :async_router,
|
|
11
|
+
:lock_snooze_base_delay, :lock_snooze_jitter, :lock_snooze_max_attempts
|
|
11
12
|
|
|
12
13
|
def sidekiq_queue
|
|
13
14
|
@sidekiq_queue ||= :default
|
|
@@ -17,12 +18,28 @@ module RubyReactor
|
|
|
17
18
|
@sidekiq_retry_count ||= 3
|
|
18
19
|
end
|
|
19
20
|
|
|
21
|
+
# Base seconds the Sidekiq worker waits before re-checking a contended lock.
|
|
22
|
+
def lock_snooze_base_delay
|
|
23
|
+
@lock_snooze_base_delay ||= 5
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Extra random seconds added to the base delay to avoid thundering herd.
|
|
27
|
+
def lock_snooze_jitter
|
|
28
|
+
@lock_snooze_jitter ||= 5
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# How many times a single job can snooze on lock contention before it is
|
|
32
|
+
# marked as failed. Set to :infinity to never escalate.
|
|
33
|
+
def lock_snooze_max_attempts
|
|
34
|
+
@lock_snooze_max_attempts ||= 20
|
|
35
|
+
end
|
|
36
|
+
|
|
20
37
|
def logger
|
|
21
38
|
@logger ||= Logger.new($stderr)
|
|
22
39
|
end
|
|
23
40
|
|
|
24
41
|
def async_router
|
|
25
|
-
@async_router ||= RubyReactor::
|
|
42
|
+
@async_router ||= RubyReactor::SidekiqAdapter
|
|
26
43
|
end
|
|
27
44
|
|
|
28
45
|
def storage
|
data/lib/ruby_reactor/context.rb
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module RubyReactor
|
|
4
4
|
class Context
|
|
5
5
|
attr_accessor :inputs, :intermediate_results, :private_data, :current_step, :retry_count, :concurrency_key,
|
|
6
|
-
:retry_context, :reactor_class, :execution_trace, :inline_async_execution, :undo_stack,
|
|
6
|
+
:retry_context, :reactor_class, :execution_trace, :inline_async_execution, :undo_stack,
|
|
7
7
|
:parent_context, :root_context, :composed_contexts, :context_id, :map_operations, :map_metadata,
|
|
8
8
|
:cancelled, :cancellation_reason, :parent_context_id, :status, :failure_reason
|
|
9
9
|
|
|
@@ -23,7 +23,6 @@ module RubyReactor
|
|
|
23
23
|
@execution_trace = []
|
|
24
24
|
@inline_async_execution = false # Flag to prevent nested async calls
|
|
25
25
|
@undo_stack = [] # Initialize the undo stack
|
|
26
|
-
@test_mode = false
|
|
27
26
|
@cancelled = false
|
|
28
27
|
@cancellation_reason = nil
|
|
29
28
|
@status = "pending"
|
|
@@ -33,6 +32,14 @@ module RubyReactor
|
|
|
33
32
|
@root_context = nil
|
|
34
33
|
end
|
|
35
34
|
|
|
35
|
+
def finished?
|
|
36
|
+
%w[completed failed cancelled].include?(@status.to_s)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def failed?
|
|
40
|
+
@status.to_s == "failed"
|
|
41
|
+
end
|
|
42
|
+
|
|
36
43
|
def get_input(name, path = nil)
|
|
37
44
|
value = @inputs[name.to_sym] || @inputs[name.to_s]
|
|
38
45
|
return nil if value.nil?
|
|
@@ -57,6 +64,10 @@ module RubyReactor
|
|
|
57
64
|
end
|
|
58
65
|
alias result get_result
|
|
59
66
|
|
|
67
|
+
def has_result?(step_name)
|
|
68
|
+
@intermediate_results.key?(step_name.to_sym) || @intermediate_results.key?(step_name.to_s)
|
|
69
|
+
end
|
|
70
|
+
|
|
60
71
|
def set_result(step_name, value)
|
|
61
72
|
@intermediate_results[step_name.to_sym] = value
|
|
62
73
|
end
|
|
@@ -81,7 +92,6 @@ module RubyReactor
|
|
|
81
92
|
retry_context: @retry_context,
|
|
82
93
|
reactor_class: @reactor_class,
|
|
83
94
|
execution_trace: @execution_trace,
|
|
84
|
-
test_mode: @test_mode,
|
|
85
95
|
status: @status,
|
|
86
96
|
failure_reason: @failure_reason
|
|
87
97
|
}
|
|
@@ -105,7 +115,6 @@ module RubyReactor
|
|
|
105
115
|
retry_context: @retry_context.serialize_for_retry,
|
|
106
116
|
execution_trace: ContextSerializer.serialize_value(@execution_trace),
|
|
107
117
|
undo_stack: serialize_undo_stack,
|
|
108
|
-
test_mode: @test_mode,
|
|
109
118
|
cancelled: @cancelled,
|
|
110
119
|
cancellation_reason: @cancellation_reason,
|
|
111
120
|
status: @status,
|
|
@@ -130,7 +139,6 @@ module RubyReactor
|
|
|
130
139
|
context.retry_context = RetryContext.deserialize_from_retry(data["retry_context"] || {})
|
|
131
140
|
context.execution_trace = ContextSerializer.deserialize_value(data["execution_trace"]) || []
|
|
132
141
|
context.undo_stack = deserialize_undo_stack(data["undo_stack"] || [], context.reactor_class)
|
|
133
|
-
context.test_mode = data["test_mode"] || false
|
|
134
142
|
context.cancelled = data["cancelled"] || false
|
|
135
143
|
context.cancellation_reason = data["cancellation_reason"]
|
|
136
144
|
context.status = data["status"] || "pending"
|
|
@@ -34,9 +34,25 @@ module RubyReactor
|
|
|
34
34
|
when RubyReactor::Success
|
|
35
35
|
{ "_type" => "Success", "value" => serialize_value(value.value) }
|
|
36
36
|
when RubyReactor::Failure
|
|
37
|
-
{
|
|
37
|
+
{
|
|
38
|
+
"_type" => "Failure",
|
|
39
|
+
"error" => serialize_value(value.error),
|
|
40
|
+
"retryable" => value.retryable,
|
|
41
|
+
"step_name" => value.step_name,
|
|
42
|
+
"inputs" => serialize_value(value.inputs),
|
|
43
|
+
"backtrace" => value.backtrace,
|
|
44
|
+
"reactor_name" => value.reactor_name,
|
|
45
|
+
"step_arguments" => serialize_value(value.step_arguments),
|
|
46
|
+
"exception_class" => value.exception_class,
|
|
47
|
+
"file_path" => value.file_path,
|
|
48
|
+
"line_number" => value.line_number,
|
|
49
|
+
"code_snippet" => serialize_value(value.code_snippet),
|
|
50
|
+
"validation_errors" => serialize_value(value.validation_errors)
|
|
51
|
+
}
|
|
38
52
|
when RubyReactor::Context
|
|
39
53
|
{ "_type" => "Context", "value" => value.serialize_for_retry }
|
|
54
|
+
when Symbol
|
|
55
|
+
{ "_type" => "Symbol", "value" => value.to_s }
|
|
40
56
|
when Time
|
|
41
57
|
{ "_type" => "Time", "value" => value.iso8601 }
|
|
42
58
|
when BigDecimal
|
|
@@ -94,9 +110,24 @@ module RubyReactor
|
|
|
94
110
|
when "Success"
|
|
95
111
|
RubyReactor::Success(deserialize_value(value["value"]))
|
|
96
112
|
when "Failure"
|
|
97
|
-
RubyReactor::Failure(
|
|
113
|
+
RubyReactor::Failure.new(
|
|
114
|
+
deserialize_value(value["error"]),
|
|
115
|
+
retryable: value["retryable"],
|
|
116
|
+
step_name: value["step_name"],
|
|
117
|
+
inputs: deserialize_value(value["inputs"]),
|
|
118
|
+
backtrace: value["backtrace"],
|
|
119
|
+
reactor_name: value["reactor_name"],
|
|
120
|
+
step_arguments: deserialize_value(value["step_arguments"]),
|
|
121
|
+
exception_class: value["exception_class"],
|
|
122
|
+
file_path: value["file_path"],
|
|
123
|
+
line_number: value["line_number"],
|
|
124
|
+
code_snippet: deserialize_value(value["code_snippet"]),
|
|
125
|
+
validation_errors: deserialize_value(value["validation_errors"])
|
|
126
|
+
)
|
|
98
127
|
when "Context"
|
|
99
128
|
Context.deserialize_from_retry(value["value"])
|
|
129
|
+
when "Symbol"
|
|
130
|
+
value["value"].to_sym
|
|
100
131
|
when "Time"
|
|
101
132
|
Time.iso8601(value["value"])
|
|
102
133
|
when "BigDecimal"
|
|
@@ -130,11 +161,13 @@ module RubyReactor
|
|
|
130
161
|
strict_ordering: value["strict_ordering"],
|
|
131
162
|
batch_size: value["batch_size"]
|
|
132
163
|
)
|
|
164
|
+
|
|
133
165
|
else
|
|
134
|
-
|
|
166
|
+
# Unknown type wrapper, return as is (but deserialize values)
|
|
167
|
+
value.transform_values { |v| deserialize_value(v) }
|
|
135
168
|
end
|
|
136
169
|
else
|
|
137
|
-
# Regular
|
|
170
|
+
# Regular Hash
|
|
138
171
|
value.transform_keys(&:to_sym).transform_values { |v| deserialize_value(v) }
|
|
139
172
|
end
|
|
140
173
|
when Array
|
|
@@ -143,6 +176,24 @@ module RubyReactor
|
|
|
143
176
|
value
|
|
144
177
|
end
|
|
145
178
|
end
|
|
179
|
+
|
|
180
|
+
# Simplifies data for public API usage (removes wrappers, flattens types)
|
|
181
|
+
def simplify_for_api(value)
|
|
182
|
+
case value
|
|
183
|
+
when Hash
|
|
184
|
+
value.each_with_object({}) do |(k, v), hash|
|
|
185
|
+
hash[k.to_s] = simplify_for_api(v)
|
|
186
|
+
end
|
|
187
|
+
when Array
|
|
188
|
+
value.map { |v| simplify_for_api(v) }
|
|
189
|
+
when Success, Failure, Context
|
|
190
|
+
simplify_for_api(value.to_h)
|
|
191
|
+
when Symbol
|
|
192
|
+
value.to_s
|
|
193
|
+
else
|
|
194
|
+
value
|
|
195
|
+
end
|
|
196
|
+
end
|
|
146
197
|
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength
|
|
147
198
|
|
|
148
199
|
private
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
module Dsl
|
|
5
|
+
module Lockable
|
|
6
|
+
def self.included(base)
|
|
7
|
+
base.extend(ClassMethods)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
module ClassMethods
|
|
11
|
+
attr_reader :lock_config, :semaphore_config, :period_config, :rate_limit_config
|
|
12
|
+
|
|
13
|
+
# Propagate lock/semaphore/period/rate-limit config to subclasses;
|
|
14
|
+
# without this a subclass of a configured reactor would silently lose
|
|
15
|
+
# those settings.
|
|
16
|
+
def inherited(subclass)
|
|
17
|
+
super
|
|
18
|
+
subclass.instance_variable_set(:@lock_config, @lock_config) if @lock_config
|
|
19
|
+
subclass.instance_variable_set(:@semaphore_config, @semaphore_config) if @semaphore_config
|
|
20
|
+
subclass.instance_variable_set(:@period_config, @period_config) if @period_config
|
|
21
|
+
subclass.instance_variable_set(:@rate_limit_config, @rate_limit_config) if @rate_limit_config
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Configure locking for this reactor
|
|
25
|
+
# @param ttl [Integer] Time to live in seconds (default: 60)
|
|
26
|
+
# @param wait [Integer] Time to wait for lock in seconds (default: 0)
|
|
27
|
+
# @param auto_extend [Boolean] When true (default), a background thread
|
|
28
|
+
# refreshes the lock TTL every ttl/3 seconds while the reactor runs,
|
|
29
|
+
# protecting steps that may legitimately outlast `ttl`. Pass `false`
|
|
30
|
+
# to disable and rely solely on `ttl` for expiry.
|
|
31
|
+
# @yield [inputs] Block that returns the lock key string
|
|
32
|
+
def with_lock(ttl: 60, wait: 0, auto_extend: true, &block)
|
|
33
|
+
@lock_config = {
|
|
34
|
+
ttl: ttl,
|
|
35
|
+
wait: wait,
|
|
36
|
+
auto_extend: auto_extend,
|
|
37
|
+
key_proc: block
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Configure semaphore for this reactor
|
|
42
|
+
# @param limit [Integer] Maximum concurrent executions
|
|
43
|
+
# @param wait [Integer] Time to wait for a token in seconds (default: 0)
|
|
44
|
+
# @yield [inputs] Block that returns the semaphore key string
|
|
45
|
+
def with_semaphore(limit:, wait: 0, &block)
|
|
46
|
+
@semaphore_config = {
|
|
47
|
+
limit: limit,
|
|
48
|
+
wait: wait,
|
|
49
|
+
key_proc: block
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Configure a calendar-aligned dedup window for this reactor. The
|
|
54
|
+
# reactor will run at most once per bucket per key; subsequent calls
|
|
55
|
+
# in the same bucket return `RubyReactor::Skipped` without executing
|
|
56
|
+
# any steps.
|
|
57
|
+
#
|
|
58
|
+
# Note: `with_period` is *dedup*, not *concurrency*. Two concurrent
|
|
59
|
+
# racers can both see no marker and both run. Pair with `with_lock`
|
|
60
|
+
# for true at-most-one semantics within the bucket.
|
|
61
|
+
#
|
|
62
|
+
# @param every [Symbol, Integer] :minute / :hour / :day / :week /
|
|
63
|
+
# :month / :year, or an integer number of seconds for a sliding
|
|
64
|
+
# bucket (index = `time.to_i / every`).
|
|
65
|
+
# @yield [inputs] Block that returns the period key base. The final
|
|
66
|
+
# Redis marker key is `period:<base>:<bucket_id>`.
|
|
67
|
+
def with_period(every:, &block)
|
|
68
|
+
# Validate eagerly so misconfiguration surfaces at class load time.
|
|
69
|
+
RubyReactor::Period.period_seconds(every)
|
|
70
|
+
|
|
71
|
+
@period_config = {
|
|
72
|
+
every: every,
|
|
73
|
+
key_proc: block
|
|
74
|
+
}
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Configure rate limiting for this reactor (fixed-window counter).
|
|
78
|
+
# Pass either a single window via `limit:` + `period:`, or a hash of
|
|
79
|
+
# windows via `limits:` for layered API quotas.
|
|
80
|
+
#
|
|
81
|
+
# @example Single window
|
|
82
|
+
# with_rate_limit(limit: 3, period: :second) { |i| "stripe:#{i[:account_id]}" }
|
|
83
|
+
#
|
|
84
|
+
# @example Multi-window (3/sec AND 100/min AND 5000/hr)
|
|
85
|
+
# with_rate_limit(
|
|
86
|
+
# limits: { second: 3, minute: 100, hour: 5000 }
|
|
87
|
+
# ) { |i| "stripe:#{i[:account_id]}" }
|
|
88
|
+
#
|
|
89
|
+
# @param limit [Integer] requests per period (single-window form)
|
|
90
|
+
# @param period [Symbol, Integer] :second / :minute / :hour / :day /
|
|
91
|
+
# :week / :month / :year, or integer seconds (single-window form)
|
|
92
|
+
# @param limits [Hash{Symbol,Integer => Integer}] mapping of period
|
|
93
|
+
# unit to limit (multi-window form)
|
|
94
|
+
# @yield [inputs] Block returning the rate-limit key base.
|
|
95
|
+
def with_rate_limit(limit: nil, period: nil, limits: nil, &block)
|
|
96
|
+
normalized = normalize_rate_limit_args(limit, period, limits)
|
|
97
|
+
|
|
98
|
+
@rate_limit_config = {
|
|
99
|
+
limits: normalized,
|
|
100
|
+
key_proc: block
|
|
101
|
+
}
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def normalize_rate_limit_args(limit, period, limits)
|
|
107
|
+
if limits
|
|
108
|
+
raise ArgumentError, "with_rate_limit: use either :limits, or :limit + :period, not both" if limit || period
|
|
109
|
+
|
|
110
|
+
limits.map do |period_key, limit_val|
|
|
111
|
+
{
|
|
112
|
+
period_seconds: RubyReactor::Period.period_seconds(period_key),
|
|
113
|
+
limit: Integer(limit_val),
|
|
114
|
+
name: period_key.to_s
|
|
115
|
+
}
|
|
116
|
+
end
|
|
117
|
+
elsif limit && period
|
|
118
|
+
[{
|
|
119
|
+
period_seconds: RubyReactor::Period.period_seconds(period),
|
|
120
|
+
limit: Integer(limit),
|
|
121
|
+
name: period.to_s
|
|
122
|
+
}]
|
|
123
|
+
else
|
|
124
|
+
raise ArgumentError, "with_rate_limit requires :limit + :period, or :limits"
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -3,11 +3,14 @@
|
|
|
3
3
|
module RubyReactor
|
|
4
4
|
module Error
|
|
5
5
|
class StepFailureError < Base
|
|
6
|
-
attr_reader :step_arguments
|
|
6
|
+
attr_reader :step_arguments, :exception_class
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
# rubocop:disable Metrics/ParameterLists
|
|
9
|
+
def initialize(message, step: nil, context: nil, original_error: nil, step_arguments: {}, exception_class: nil)
|
|
10
|
+
# rubocop:enable Metrics/ParameterLists
|
|
9
11
|
super(message, step: step, context: context, original_error: original_error)
|
|
10
12
|
@step_arguments = step_arguments
|
|
13
|
+
@exception_class = exception_class
|
|
11
14
|
end
|
|
12
15
|
|
|
13
16
|
def retryable?
|
|
@@ -14,6 +14,9 @@ module RubyReactor
|
|
|
14
14
|
|
|
15
15
|
def handle_step_result(step_config, result, resolved_arguments)
|
|
16
16
|
case result
|
|
17
|
+
when RubyReactor::Skipped
|
|
18
|
+
# Important: must come before the Success branch — Skipped < Success.
|
|
19
|
+
handle_skipped(step_config, result)
|
|
17
20
|
when RubyReactor::Success
|
|
18
21
|
handle_success(step_config, result, resolved_arguments)
|
|
19
22
|
when RubyReactor::MaxRetriesExhaustedFailure
|
|
@@ -31,7 +34,7 @@ module RubyReactor
|
|
|
31
34
|
handle_step_failure_error(error)
|
|
32
35
|
when Error::InputValidationError
|
|
33
36
|
# Preserve validation errors as-is for proper error handling
|
|
34
|
-
RubyReactor.Failure(error)
|
|
37
|
+
RubyReactor.Failure(error, validation_errors: error.field_errors)
|
|
35
38
|
when Error::Base
|
|
36
39
|
# Other errors need rollback
|
|
37
40
|
@compensation_manager.rollback_completed_steps
|
|
@@ -53,6 +56,22 @@ module RubyReactor
|
|
|
53
56
|
|
|
54
57
|
private
|
|
55
58
|
|
|
59
|
+
# A step returned `RubyReactor.Skipped(...)`. Halt cleanly: record the
|
|
60
|
+
# event in the trace, do NOT push to the undo stack (so existing
|
|
61
|
+
# completed steps stay as-is — no compensation), and stamp the step
|
|
62
|
+
# name on the result so the caller can see who halted.
|
|
63
|
+
def handle_skipped(step_config, result)
|
|
64
|
+
@step_results[step_config.name] = result
|
|
65
|
+
result.instance_variable_set(:@step_name, step_config.name) if result.step_name.nil?
|
|
66
|
+
@context.execution_trace << {
|
|
67
|
+
type: :skipped,
|
|
68
|
+
step: step_config.name,
|
|
69
|
+
timestamp: Time.now,
|
|
70
|
+
reason: result.reason
|
|
71
|
+
}
|
|
72
|
+
result
|
|
73
|
+
end
|
|
74
|
+
|
|
56
75
|
def handle_success(step_config, result, resolved_arguments)
|
|
57
76
|
validate_step_output(step_config, result.value, resolved_arguments)
|
|
58
77
|
@step_results[step_config.name] = result
|
|
@@ -129,7 +148,7 @@ module RubyReactor
|
|
|
129
148
|
|
|
130
149
|
def create_failure_from_error(error, redact_inputs)
|
|
131
150
|
original_error = error.original_error
|
|
132
|
-
exception_class = original_error
|
|
151
|
+
exception_class = resolve_exception_class(original_error, error)
|
|
133
152
|
backtrace = original_error&.backtrace || error.backtrace
|
|
134
153
|
file_path, line_number = extract_location(backtrace)
|
|
135
154
|
code_snippet = RubyReactor::Utils::CodeExtractor.extract(file_path, line_number) if file_path
|
|
@@ -149,6 +168,12 @@ module RubyReactor
|
|
|
149
168
|
)
|
|
150
169
|
end
|
|
151
170
|
|
|
171
|
+
def resolve_exception_class(original_error, error)
|
|
172
|
+
return original_error.class.name if original_error
|
|
173
|
+
|
|
174
|
+
error.respond_to?(:exception_class) ? error.exception_class : nil
|
|
175
|
+
end
|
|
176
|
+
|
|
152
177
|
def validate_step_output(step_config, value, resolved_arguments = {})
|
|
153
178
|
return unless step_config.output_validator
|
|
154
179
|
|
|
@@ -116,7 +116,8 @@ module RubyReactor
|
|
|
116
116
|
@context.root_context&.reactor_class&.async? ||
|
|
117
117
|
@context.inline_async_execution
|
|
118
118
|
|
|
119
|
-
|
|
119
|
+
# Always try async retry if configured
|
|
120
|
+
if is_async
|
|
120
121
|
handle_async_retry(step_config, reactor_class, result)
|
|
121
122
|
else
|
|
122
123
|
handle_sync_retry(step_config, reactor_class, result)
|
|
@@ -124,12 +125,19 @@ module RubyReactor
|
|
|
124
125
|
end
|
|
125
126
|
|
|
126
127
|
def handle_async_retry(step_config, reactor_class, result)
|
|
127
|
-
requeue_job_for_step_retry(step_config, result.error, reactor_class)
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
128
|
+
requeue_result = requeue_job_for_step_retry(step_config, result.error, reactor_class)
|
|
129
|
+
|
|
130
|
+
# If it returned an AsyncResult, we are truly async.
|
|
131
|
+
# Otherwise, it ran inline and we should return the result of that execution.
|
|
132
|
+
if requeue_result.is_a?(RubyReactor::AsyncResult)
|
|
133
|
+
RetryQueuedResult.new(
|
|
134
|
+
step_config.name,
|
|
135
|
+
@context.retry_context.attempts_for_step(step_config.name),
|
|
136
|
+
@context.retry_context.next_retry_at
|
|
137
|
+
)
|
|
138
|
+
else
|
|
139
|
+
requeue_result
|
|
140
|
+
end
|
|
133
141
|
end
|
|
134
142
|
|
|
135
143
|
def handle_sync_retry(step_config, reactor_class, result)
|
|
@@ -13,7 +13,7 @@ module RubyReactor
|
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
def execute_all_steps
|
|
16
|
-
until @dependency_graph.all_completed?
|
|
16
|
+
until @dependency_graph.all_completed? || @context.finished?
|
|
17
17
|
ready_steps = @dependency_graph.ready_steps
|
|
18
18
|
|
|
19
19
|
if ready_steps.empty?
|
|
@@ -33,6 +33,11 @@ module RubyReactor
|
|
|
33
33
|
# If a step returns RetryQueuedResult, we need to stop and return it
|
|
34
34
|
return result if result.is_a?(RetryQueuedResult)
|
|
35
35
|
|
|
36
|
+
# If a step returns Skipped, halt the reactor cleanly (no
|
|
37
|
+
# compensation). Must be checked BEFORE Failure / Success because
|
|
38
|
+
# Skipped is a Success subclass.
|
|
39
|
+
return result if result.is_a?(RubyReactor::Skipped)
|
|
40
|
+
|
|
36
41
|
# If a step returns Failure, we need to stop execution and return it
|
|
37
42
|
return result if result.is_a?(RubyReactor::Failure)
|
|
38
43
|
|
|
@@ -65,53 +70,29 @@ module RubyReactor
|
|
|
65
70
|
end
|
|
66
71
|
end
|
|
67
72
|
|
|
68
|
-
|
|
69
|
-
# Merge the state from the async-executed executor back into ours
|
|
70
|
-
# We need to update our context IN PLACE, not replace the reference,
|
|
71
|
-
# because the Executor also holds a reference to the same context object
|
|
72
|
-
|
|
73
|
-
# Update intermediate results
|
|
74
|
-
other_executor.context.intermediate_results.each do |step_name, value|
|
|
75
|
-
@context.set_result(step_name, value)
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
# Append execution trace from the async execution
|
|
79
|
-
# The Worker's execution will have ALL steps including ones we already executed,
|
|
80
|
-
# but we only want to add the NEW entries (from current_step onwards)
|
|
81
|
-
current_trace_length = @context.execution_trace.length
|
|
82
|
-
new_trace_entries = other_executor.context.execution_trace[current_trace_length..] || []
|
|
83
|
-
|
|
84
|
-
@context.execution_trace.concat(new_trace_entries)
|
|
85
|
-
|
|
86
|
-
# Update retry context
|
|
87
|
-
@context.retry_context = other_executor.context.retry_context
|
|
88
|
-
|
|
89
|
-
# Update current_step:
|
|
90
|
-
# If the other executor has a current_step, it means it paused/interrupted there. We should adopt it.
|
|
91
|
-
# If it's nil, it means it completed successfully, so we clear our current_step (which was the async step).
|
|
92
|
-
@context.current_step = other_executor.context.current_step
|
|
93
|
-
|
|
94
|
-
# Update our dependency graph to reflect completed steps
|
|
95
|
-
other_executor.context.intermediate_results.each_key do |step_name|
|
|
96
|
-
@dependency_graph.complete_step(step_name)
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
# Also mark the current_step as completed if it exists (for failed steps that don't have results)
|
|
100
|
-
@dependency_graph.complete_step(other_executor.context.current_step) if other_executor.context.current_step
|
|
101
|
-
|
|
102
|
-
# Merge any undo stack items
|
|
103
|
-
other_executor.undo_stack.each do |item|
|
|
104
|
-
# Avoid duplicates by checking if this step is already in the undo stack
|
|
105
|
-
# Use string comparison for step names to avoid symbol/string mismatch issues
|
|
106
|
-
unless @compensation_manager.undo_stack.any? { |existing| existing[:step].name.to_s == item[:step].name.to_s }
|
|
107
|
-
@compensation_manager.add_to_undo_stack(item)
|
|
108
|
-
end
|
|
109
|
-
end
|
|
73
|
+
private
|
|
110
74
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
75
|
+
def reconstruct_failure(data)
|
|
76
|
+
return data if data.is_a?(RubyReactor::Failure)
|
|
77
|
+
return nil unless data.is_a?(Hash)
|
|
78
|
+
|
|
79
|
+
# Helper for hash access with string/symbol keys
|
|
80
|
+
get = ->(key) { data[key] || data[key.to_s] }
|
|
81
|
+
|
|
82
|
+
RubyReactor::Failure.new(
|
|
83
|
+
get.call(:message),
|
|
84
|
+
step_name: get.call(:step_name),
|
|
85
|
+
inputs: get.call(:inputs),
|
|
86
|
+
redact_inputs: get.call(:redact_inputs) || [],
|
|
87
|
+
backtrace: get.call(:backtrace),
|
|
88
|
+
reactor_name: get.call(:reactor_name),
|
|
89
|
+
step_arguments: get.call(:step_arguments),
|
|
90
|
+
exception_class: get.call(:exception_class),
|
|
91
|
+
file_path: get.call(:file_path),
|
|
92
|
+
line_number: get.call(:line_number),
|
|
93
|
+
code_snippet: get.call(:code_snippet),
|
|
94
|
+
validation_errors: get.call(:validation_errors)
|
|
95
|
+
)
|
|
115
96
|
end
|
|
116
97
|
|
|
117
98
|
def execute_step_with_retry(step_config)
|
|
@@ -190,8 +171,6 @@ module RubyReactor
|
|
|
190
171
|
end
|
|
191
172
|
end
|
|
192
173
|
|
|
193
|
-
private
|
|
194
|
-
|
|
195
174
|
def handle_async_step(step_config)
|
|
196
175
|
# Step-level async: hand off execution to worker
|
|
197
176
|
|
|
@@ -204,60 +183,11 @@ module RubyReactor
|
|
|
204
183
|
|
|
205
184
|
serialized_context = ContextSerializer.serialize(context_to_serialize)
|
|
206
185
|
|
|
207
|
-
|
|
186
|
+
configuration.async_router.perform_async(
|
|
208
187
|
serialized_context,
|
|
209
188
|
reactor_class_name,
|
|
210
189
|
intermediate_results: @context.intermediate_results
|
|
211
190
|
)
|
|
212
|
-
|
|
213
|
-
# Handle different result types from async router
|
|
214
|
-
case result
|
|
215
|
-
when RubyReactor::AsyncResult
|
|
216
|
-
# Production behavior: return async result to caller
|
|
217
|
-
|
|
218
|
-
result
|
|
219
|
-
when Executor
|
|
220
|
-
handle_inline_executor_result(result)
|
|
221
|
-
else
|
|
222
|
-
# Unexpected result type, treat as error
|
|
223
|
-
raise Error::ValidationError.new(
|
|
224
|
-
"Unexpected result type from async router: #{result.class}",
|
|
225
|
-
context: @context
|
|
226
|
-
)
|
|
227
|
-
end
|
|
228
|
-
end
|
|
229
|
-
|
|
230
|
-
def handle_inline_executor_result(result)
|
|
231
|
-
# Worker executed inline and returned an executor.
|
|
232
|
-
# This happens when running in test mode or when perform_async returns an executor.
|
|
233
|
-
# We need to merge the state back into our current executor.
|
|
234
|
-
#
|
|
235
|
-
# If we are a child reactor, the worker executed the root reactor, so the result
|
|
236
|
-
# will be a Root executor. We handle this mismatch below by finding our
|
|
237
|
-
# corresponding child context within the root result.
|
|
238
|
-
if @context.root_context && (result.context.reactor_class != @reactor_class)
|
|
239
|
-
# We are a child, and result is root.
|
|
240
|
-
# We need to find ourselves in the root result using context_id.
|
|
241
|
-
matching_context = find_context_by_id(result.context, @context.context_id)
|
|
242
|
-
|
|
243
|
-
if matching_context
|
|
244
|
-
# Replace the result's context with the matching child context
|
|
245
|
-
# so merge_executor_state works correctly
|
|
246
|
-
result.instance_variable_set(:@context, matching_context)
|
|
247
|
-
else
|
|
248
|
-
# Fallback: if we can't find it (shouldn't happen), we might be in trouble.
|
|
249
|
-
# But let's try to proceed, maybe it's not nested?
|
|
250
|
-
# For now, raise an error to be explicit
|
|
251
|
-
raise Error::ValidationError.new(
|
|
252
|
-
"Could not find child context with ID #{@context.context_id} in root result",
|
|
253
|
-
context: @context
|
|
254
|
-
)
|
|
255
|
-
end
|
|
256
|
-
end
|
|
257
|
-
|
|
258
|
-
merge_executor_state(result)
|
|
259
|
-
|
|
260
|
-
result.result
|
|
261
191
|
end
|
|
262
192
|
|
|
263
193
|
def handle_interrupt_step(step_config)
|