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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +145 -9
  3. data/documentation/README.md +20 -8
  4. data/documentation/async_reactors.md +46 -34
  5. data/documentation/core_concepts.md +75 -61
  6. data/documentation/examples/inventory_management.md +2 -3
  7. data/documentation/examples/order_processing.md +92 -77
  8. data/documentation/examples/payment_processing.md +28 -117
  9. data/documentation/getting_started.md +112 -94
  10. data/documentation/interrupts.md +9 -7
  11. data/documentation/locks_and_semaphores.md +459 -0
  12. data/documentation/retry_configuration.md +19 -14
  13. data/documentation/testing.md +994 -0
  14. data/lib/ruby_reactor/configuration.rb +19 -2
  15. data/lib/ruby_reactor/context.rb +13 -5
  16. data/lib/ruby_reactor/context_serializer.rb +55 -4
  17. data/lib/ruby_reactor/dsl/lockable.rb +130 -0
  18. data/lib/ruby_reactor/dsl/reactor.rb +3 -2
  19. data/lib/ruby_reactor/error/step_failure_error.rb +5 -2
  20. data/lib/ruby_reactor/executor/result_handler.rb +27 -2
  21. data/lib/ruby_reactor/executor/retry_manager.rb +15 -7
  22. data/lib/ruby_reactor/executor/step_executor.rb +29 -99
  23. data/lib/ruby_reactor/executor.rb +148 -15
  24. data/lib/ruby_reactor/lock.rb +92 -0
  25. data/lib/ruby_reactor/map/collector.rb +16 -15
  26. data/lib/ruby_reactor/map/element_executor.rb +90 -104
  27. data/lib/ruby_reactor/map/execution.rb +2 -1
  28. data/lib/ruby_reactor/map/helpers.rb +2 -1
  29. data/lib/ruby_reactor/map/result_enumerator.rb +1 -1
  30. data/lib/ruby_reactor/period.rb +67 -0
  31. data/lib/ruby_reactor/rate_limit.rb +74 -0
  32. data/lib/ruby_reactor/reactor.rb +175 -16
  33. data/lib/ruby_reactor/rspec/helpers.rb +17 -0
  34. data/lib/ruby_reactor/rspec/matchers.rb +423 -0
  35. data/lib/ruby_reactor/rspec/step_executor_patch.rb +85 -0
  36. data/lib/ruby_reactor/rspec/test_subject.rb +625 -0
  37. data/lib/ruby_reactor/rspec.rb +18 -0
  38. data/lib/ruby_reactor/semaphore.rb +58 -0
  39. data/lib/ruby_reactor/{async_router.rb → sidekiq_adapter.rb} +10 -5
  40. data/lib/ruby_reactor/sidekiq_workers/worker.rb +69 -9
  41. data/lib/ruby_reactor/step/compose_step.rb +0 -1
  42. data/lib/ruby_reactor/step/map_step.rb +11 -18
  43. data/lib/ruby_reactor/storage/redis_adapter.rb +2 -0
  44. data/lib/ruby_reactor/storage/redis_locking.rb +251 -0
  45. data/lib/ruby_reactor/version.rb +1 -1
  46. data/lib/ruby_reactor/web/api.rb +32 -24
  47. data/lib/ruby_reactor.rb +119 -10
  48. 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::AsyncRouter
42
+ @async_router ||= RubyReactor::SidekiqAdapter
26
43
  end
27
44
 
28
45
  def storage
@@ -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, :test_mode,
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
- { "_type" => "Failure", "error" => serialize_value(value.error), "retryable" => value.retryable }
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(deserialize_value(value["error"]), retryable: value["retryable"])
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
- value
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 hash - symbolize all keys recursively
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
@@ -118,8 +118,9 @@ module RubyReactor
118
118
  step_config
119
119
  end
120
120
 
121
- def returns(step_name)
122
- @return_step = step_name
121
+ def returns(step_name = nil)
122
+ @return_step = step_name if step_name
123
+ @return_step
123
124
  end
124
125
 
125
126
  def middleware(middleware_class)
@@ -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
- def initialize(message, step: nil, context: nil, original_error: nil, step_arguments: {})
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&.class&.name
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
- if is_async && !@context.test_mode
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
- RetryQueuedResult.new(
129
- step_config.name,
130
- @context.retry_context.attempts_for_step(step_config.name),
131
- @context.retry_context.next_retry_at
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
- def merge_executor_state(other_executor)
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
- # Merge undo trace from the other executor
112
- other_executor.undo_trace.each do |trace_entry|
113
- @compensation_manager.undo_trace << trace_entry
114
- end
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
- result = configuration.async_router.perform_async(
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)