ruby_reactor 0.3.0 → 0.3.1

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.
@@ -22,7 +22,7 @@ module RubyReactor
22
22
  end
23
23
 
24
24
  def async_router
25
- @async_router ||= RubyReactor::AsyncRouter
25
+ @async_router ||= RubyReactor::SidekiqAdapter
26
26
  end
27
27
 
28
28
  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
@@ -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?
@@ -31,7 +31,7 @@ module RubyReactor
31
31
  handle_step_failure_error(error)
32
32
  when Error::InputValidationError
33
33
  # Preserve validation errors as-is for proper error handling
34
- RubyReactor.Failure(error)
34
+ RubyReactor.Failure(error, validation_errors: error.field_errors)
35
35
  when Error::Base
36
36
  # Other errors need rollback
37
37
  @compensation_manager.rollback_completed_steps
@@ -129,7 +129,7 @@ module RubyReactor
129
129
 
130
130
  def create_failure_from_error(error, redact_inputs)
131
131
  original_error = error.original_error
132
- exception_class = original_error&.class&.name
132
+ exception_class = resolve_exception_class(original_error, error)
133
133
  backtrace = original_error&.backtrace || error.backtrace
134
134
  file_path, line_number = extract_location(backtrace)
135
135
  code_snippet = RubyReactor::Utils::CodeExtractor.extract(file_path, line_number) if file_path
@@ -149,6 +149,12 @@ module RubyReactor
149
149
  )
150
150
  end
151
151
 
152
+ def resolve_exception_class(original_error, error)
153
+ return original_error.class.name if original_error
154
+
155
+ error.respond_to?(:exception_class) ? error.exception_class : nil
156
+ end
157
+
152
158
  def validate_step_output(step_config, value, resolved_arguments = {})
153
159
  return unless step_config.output_validator
154
160
 
@@ -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?
@@ -65,53 +65,29 @@ module RubyReactor
65
65
  end
66
66
  end
67
67
 
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
68
+ private
110
69
 
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
70
+ def reconstruct_failure(data)
71
+ return data if data.is_a?(RubyReactor::Failure)
72
+ return nil unless data.is_a?(Hash)
73
+
74
+ # Helper for hash access with string/symbol keys
75
+ get = ->(key) { data[key] || data[key.to_s] }
76
+
77
+ RubyReactor::Failure.new(
78
+ get.call(:message),
79
+ step_name: get.call(:step_name),
80
+ inputs: get.call(:inputs),
81
+ redact_inputs: get.call(:redact_inputs) || [],
82
+ backtrace: get.call(:backtrace),
83
+ reactor_name: get.call(:reactor_name),
84
+ step_arguments: get.call(:step_arguments),
85
+ exception_class: get.call(:exception_class),
86
+ file_path: get.call(:file_path),
87
+ line_number: get.call(:line_number),
88
+ code_snippet: get.call(:code_snippet),
89
+ validation_errors: get.call(:validation_errors)
90
+ )
115
91
  end
116
92
 
117
93
  def execute_step_with_retry(step_config)
@@ -190,8 +166,6 @@ module RubyReactor
190
166
  end
191
167
  end
192
168
 
193
- private
194
-
195
169
  def handle_async_step(step_config)
196
170
  # Step-level async: hand off execution to worker
197
171
 
@@ -204,60 +178,11 @@ module RubyReactor
204
178
 
205
179
  serialized_context = ContextSerializer.serialize(context_to_serialize)
206
180
 
207
- result = configuration.async_router.perform_async(
181
+ configuration.async_router.perform_async(
208
182
  serialized_context,
209
183
  reactor_class_name,
210
184
  intermediate_results: @context.intermediate_results
211
185
  )
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
186
  end
262
187
 
263
188
  def handle_interrupt_step(step_config)
@@ -40,6 +40,7 @@ module RubyReactor
40
40
  input_validator = InputValidator.new(@reactor_class, @context)
41
41
  input_validator.validate!
42
42
 
43
+ @context.status = :running
43
44
  save_context
44
45
 
45
46
  graph_manager = GraphManager.new(@reactor_class, @dependency_graph, @context)
@@ -59,6 +60,7 @@ module RubyReactor
59
60
  end
60
61
 
61
62
  def resume_execution
63
+ @context.status = :running
62
64
  prepare_for_resume
63
65
  save_context
64
66
 
@@ -118,19 +120,7 @@ module RubyReactor
118
120
  @context.status = :completed
119
121
  when RubyReactor::Failure
120
122
  @context.status = :failed
121
- @context.failure_reason = {
122
- message: result.error.is_a?(Exception) ? result.error.message : result.error.to_s,
123
- step_name: result.step_name,
124
- inputs: result.inputs,
125
- backtrace: result.backtrace,
126
- reactor_name: result.reactor_name,
127
- step_arguments: result.step_arguments,
128
- exception_class: result.exception_class,
129
- file_path: result.file_path,
130
- line_number: result.line_number,
131
- code_snippet: result.code_snippet,
132
- validation_errors: result.validation_errors
133
- }
123
+ @context.failure_reason = result
134
124
  when RubyReactor::InterruptResult
135
125
  @context.status = :paused
136
126
  end
@@ -29,21 +29,9 @@ module RubyReactor
29
29
  # Since map_offset tracks dispatching progress and might exceed count due to batching reservation,
30
30
  # we must strictly check against the total count of elements.
31
31
  # Check for fail_fast failure FIRST
32
- failed_context_id = storage.retrieve_map_failed_context_id(map_id, parent_reactor_class_name)
33
- if failed_context_id
34
- # Resolve the class of the mapped reactor to retrieve its context
35
- reactor_class = resolve_reactor_class(metadata["reactor_class_info"])
36
-
37
- failed_context_data = storage.retrieve_context(failed_context_id, reactor_class.name)
38
-
39
- if failed_context_data
40
- failed_context = RubyReactor::Context.deserialize_from_retry(failed_context_data)
41
-
42
- # Resume parent execution (which marks step as failed)
43
- resume_parent_execution(parent_context, step_name, RubyReactor::Failure(failed_context.failure_reason),
44
- storage)
45
- return
46
- end
32
+ if (failed_context_id = storage.retrieve_map_failed_context_id(map_id, parent_reactor_class_name))
33
+ handle_failure(failed_context_id, metadata, storage, parent_context, step_name)
34
+ return
47
35
  end
48
36
 
49
37
  return if results_count < total_count
@@ -96,6 +84,19 @@ module RubyReactor
96
84
  RubyReactor::Success(results)
97
85
  end
98
86
  end
87
+
88
+ def self.handle_failure(failed_context_id, metadata, storage, parent_context, step_name)
89
+ # Resolve the class of the mapped reactor to retrieve its context
90
+ reactor_class = resolve_reactor_class(metadata["reactor_class_info"])
91
+ failed_context_data = storage.retrieve_context(failed_context_id, reactor_class.name)
92
+
93
+ return unless failed_context_data
94
+
95
+ failed_context = RubyReactor::Context.deserialize_from_retry(failed_context_data)
96
+ reason = failed_context.failure_reason
97
+ result = reason.is_a?(RubyReactor::Failure) ? reason : RubyReactor::Failure(reason)
98
+ resume_parent_execution(parent_context, step_name, result, storage)
99
+ end
99
100
  end
100
101
  end
101
102
  end