ruby_reactor 0.1.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.
Files changed (75) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +98 -0
  4. data/CODE_OF_CONDUCT.md +84 -0
  5. data/README.md +570 -0
  6. data/Rakefile +12 -0
  7. data/documentation/DAG.md +457 -0
  8. data/documentation/README.md +123 -0
  9. data/documentation/async_reactors.md +369 -0
  10. data/documentation/composition.md +199 -0
  11. data/documentation/core_concepts.md +662 -0
  12. data/documentation/data_pipelines.md +224 -0
  13. data/documentation/examples/inventory_management.md +749 -0
  14. data/documentation/examples/order_processing.md +365 -0
  15. data/documentation/examples/payment_processing.md +654 -0
  16. data/documentation/getting_started.md +224 -0
  17. data/documentation/retry_configuration.md +357 -0
  18. data/lib/ruby_reactor/async_router.rb +91 -0
  19. data/lib/ruby_reactor/configuration.rb +41 -0
  20. data/lib/ruby_reactor/context.rb +169 -0
  21. data/lib/ruby_reactor/context_serializer.rb +164 -0
  22. data/lib/ruby_reactor/dependency_graph.rb +126 -0
  23. data/lib/ruby_reactor/dsl/compose_builder.rb +86 -0
  24. data/lib/ruby_reactor/dsl/map_builder.rb +112 -0
  25. data/lib/ruby_reactor/dsl/reactor.rb +151 -0
  26. data/lib/ruby_reactor/dsl/step_builder.rb +177 -0
  27. data/lib/ruby_reactor/dsl/template_helpers.rb +36 -0
  28. data/lib/ruby_reactor/dsl/validation_helpers.rb +35 -0
  29. data/lib/ruby_reactor/error/base.rb +16 -0
  30. data/lib/ruby_reactor/error/compensation_error.rb +8 -0
  31. data/lib/ruby_reactor/error/context_too_large_error.rb +11 -0
  32. data/lib/ruby_reactor/error/dependency_error.rb +8 -0
  33. data/lib/ruby_reactor/error/deserialization_error.rb +11 -0
  34. data/lib/ruby_reactor/error/input_validation_error.rb +29 -0
  35. data/lib/ruby_reactor/error/schema_version_error.rb +11 -0
  36. data/lib/ruby_reactor/error/step_failure_error.rb +18 -0
  37. data/lib/ruby_reactor/error/undo_error.rb +8 -0
  38. data/lib/ruby_reactor/error/validation_error.rb +8 -0
  39. data/lib/ruby_reactor/executor/compensation_manager.rb +79 -0
  40. data/lib/ruby_reactor/executor/graph_manager.rb +41 -0
  41. data/lib/ruby_reactor/executor/input_validator.rb +39 -0
  42. data/lib/ruby_reactor/executor/result_handler.rb +103 -0
  43. data/lib/ruby_reactor/executor/retry_manager.rb +156 -0
  44. data/lib/ruby_reactor/executor/step_executor.rb +319 -0
  45. data/lib/ruby_reactor/executor.rb +123 -0
  46. data/lib/ruby_reactor/map/collector.rb +65 -0
  47. data/lib/ruby_reactor/map/element_executor.rb +154 -0
  48. data/lib/ruby_reactor/map/execution.rb +60 -0
  49. data/lib/ruby_reactor/map/helpers.rb +67 -0
  50. data/lib/ruby_reactor/max_retries_exhausted_failure.rb +19 -0
  51. data/lib/ruby_reactor/reactor.rb +75 -0
  52. data/lib/ruby_reactor/retry_context.rb +92 -0
  53. data/lib/ruby_reactor/retry_queued_result.rb +26 -0
  54. data/lib/ruby_reactor/sidekiq_workers/map_collector_worker.rb +13 -0
  55. data/lib/ruby_reactor/sidekiq_workers/map_element_worker.rb +13 -0
  56. data/lib/ruby_reactor/sidekiq_workers/map_execution_worker.rb +15 -0
  57. data/lib/ruby_reactor/sidekiq_workers/worker.rb +55 -0
  58. data/lib/ruby_reactor/step/compose_step.rb +107 -0
  59. data/lib/ruby_reactor/step/map_step.rb +234 -0
  60. data/lib/ruby_reactor/step.rb +33 -0
  61. data/lib/ruby_reactor/storage/adapter.rb +51 -0
  62. data/lib/ruby_reactor/storage/configuration.rb +15 -0
  63. data/lib/ruby_reactor/storage/redis_adapter.rb +140 -0
  64. data/lib/ruby_reactor/template/base.rb +15 -0
  65. data/lib/ruby_reactor/template/element.rb +25 -0
  66. data/lib/ruby_reactor/template/input.rb +48 -0
  67. data/lib/ruby_reactor/template/result.rb +48 -0
  68. data/lib/ruby_reactor/template/value.rb +22 -0
  69. data/lib/ruby_reactor/validation/base.rb +26 -0
  70. data/lib/ruby_reactor/validation/input_validator.rb +62 -0
  71. data/lib/ruby_reactor/validation/schema_builder.rb +17 -0
  72. data/lib/ruby_reactor/version.rb +5 -0
  73. data/lib/ruby_reactor.rb +159 -0
  74. data/sig/ruby_reactor.rbs +4 -0
  75. metadata +178 -0
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ class Executor
5
+ class InputValidator
6
+ def initialize(reactor_class, context)
7
+ @reactor_class = reactor_class
8
+ @context = context
9
+ end
10
+
11
+ def validate!
12
+ check_required_inputs
13
+ validate_input_schemas
14
+ end
15
+
16
+ private
17
+
18
+ def check_required_inputs
19
+ @reactor_class.inputs.each do |input_name, input_config|
20
+ next if input_config[:optional] || @context.inputs.key?(input_name) || @context.inputs.key?(input_name.to_s)
21
+
22
+ raise Error::ValidationError.new(
23
+ "Required input '#{input_name}' is missing",
24
+ context: @context
25
+ )
26
+ end
27
+ end
28
+
29
+ def validate_input_schemas
30
+ return unless @reactor_class.respond_to?(:input_validations) && @reactor_class.input_validations.any?
31
+
32
+ validation_result = @reactor_class.validate_inputs(@context.inputs)
33
+ return unless validation_result.failure?
34
+
35
+ raise validation_result.error
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ class Executor
5
+ class ResultHandler
6
+ def initialize(context:, compensation_manager:, dependency_graph:)
7
+ @context = context
8
+ @compensation_manager = compensation_manager
9
+ @dependency_graph = dependency_graph
10
+ @step_results = {}
11
+ end
12
+
13
+ attr_reader :step_results
14
+
15
+ def handle_step_result(step_config, result, resolved_arguments)
16
+ case result
17
+ when RubyReactor::Success
18
+ validate_step_output(step_config, result.value, resolved_arguments)
19
+ @step_results[step_config.name] = result
20
+ @compensation_manager.add_to_undo_stack({ step: step_config, arguments: resolved_arguments, result: result })
21
+ @context.set_result(step_config.name, result.value)
22
+ @dependency_graph.complete_step(step_config.name)
23
+ when RubyReactor::MaxRetriesExhaustedFailure
24
+ # For MaxRetriesExhaustedFailure, use the original error to avoid double-wrapping the message
25
+ # The error message from MaxRetriesExhaustedFailure already includes "failed after N attempts"
26
+ @compensation_manager.handle_step_failure(step_config, result.original_error, resolved_arguments)
27
+ # Use the MaxRetriesExhaustedFailure error message for the final error
28
+ raise Error::StepFailureError.new(result.error, step: step_config.name, context: @context,
29
+ step_arguments: resolved_arguments)
30
+ when RubyReactor::Failure
31
+ failure_result = @compensation_manager.handle_step_failure(step_config, result.error, resolved_arguments)
32
+ raise Error::StepFailureError.new(failure_result.error, step: step_config.name, context: @context,
33
+ step_arguments: resolved_arguments)
34
+ else
35
+ # Treat non-Success/Failure results as success with that value
36
+ validate_step_output(step_config, result, resolved_arguments)
37
+ success_result = RubyReactor.Success(result)
38
+ @step_results[step_config.name] = success_result
39
+ @compensation_manager.add_to_undo_stack({ step: step_config, arguments: resolved_arguments,
40
+ result: success_result })
41
+ @context.set_result(step_config.name, result)
42
+ @dependency_graph.complete_step(step_config.name)
43
+ end
44
+ end
45
+
46
+ def handle_execution_error(error)
47
+ case error
48
+ when Error::StepFailureError
49
+ # Step failure has already been handled (compensation and rollback for the failed step)
50
+ # But we need to rollback all completed steps
51
+ @compensation_manager.rollback_completed_steps
52
+
53
+ redact_inputs = error.context.reactor_class.inputs.select { |_, config| config[:redact] }.keys
54
+
55
+ RubyReactor::Failure(
56
+ error.message,
57
+ step_name: error.step,
58
+ inputs: error.context.inputs,
59
+ redact_inputs: redact_inputs,
60
+ backtrace: error.backtrace,
61
+ reactor_name: error.context.reactor_class.name,
62
+ step_arguments: error.step_arguments
63
+ )
64
+ when Error::InputValidationError
65
+ # Preserve validation errors as-is for proper error handling
66
+ RubyReactor.Failure(error)
67
+ when Error::Base
68
+ # Other errors need rollback
69
+ @compensation_manager.rollback_completed_steps
70
+ RubyReactor.Failure("Execution error: #{error.message}")
71
+ else
72
+ # Unknown errors - don't rollback as they may not be reactor-related
73
+ RubyReactor.Failure("Execution failed: #{error.message}")
74
+ end
75
+ end
76
+
77
+ def final_result(reactor_class)
78
+ if reactor_class.return_step
79
+ result_value = @context.get_result(reactor_class.return_step)
80
+ RubyReactor.Success(result_value)
81
+ else
82
+ RubyReactor.Success(@context.intermediate_results)
83
+ end
84
+ end
85
+
86
+ private
87
+
88
+ def validate_step_output(step_config, value, resolved_arguments = {})
89
+ return unless step_config.output_validator
90
+
91
+ output_validation_result = step_config.output_validator.call(value)
92
+ return if output_validation_result.success?
93
+
94
+ raise Error::StepFailureError.new(
95
+ "Step '#{step_config.name}' output validation failed: #{output_validation_result.error.message}",
96
+ step: step_config.name,
97
+ context: @context,
98
+ step_arguments: resolved_arguments
99
+ )
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ class Executor
5
+ class RetryManager
6
+ def initialize(context)
7
+ @context = context
8
+ end
9
+
10
+ def execute_with_retry(step_config, reactor_class)
11
+ loop do
12
+ prepare_retry_attempt(step_config)
13
+ result = yield
14
+ handled_result = handle_retry_result(step_config, reactor_class, result)
15
+ return handled_result if handled_result
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ def can_retry_step?(step_config)
22
+ step_config.retryable? && @context.retry_context.can_retry_step?(step_config.name,
23
+ step_config.retry_config[:max_attempts])
24
+ end
25
+
26
+ def calculate_backoff_delay(step_config, _error, reactor_class)
27
+ attempt_number = @context.retry_context.attempts_for_step(step_config.name)
28
+ backoff_strategy = step_config.retry_config[:backoff] || reactor_class.retry_defaults[:backoff]
29
+ base_delay = step_config.retry_config[:base_delay] || reactor_class.retry_defaults[:base_delay]
30
+
31
+ delay = RetryContext.calculate_backoff_delay(attempt_number, backoff_strategy, base_delay)
32
+ @context.retry_context.next_retry_at = Time.now + delay
33
+ delay
34
+ end
35
+
36
+ def requeue_job_for_step_retry(step_config, error, reactor_class)
37
+ delay = calculate_backoff_delay(step_config, error, reactor_class)
38
+
39
+ # Serialize context and requeue the job
40
+ # Use root context if available to ensure we serialize the full tree
41
+ context_to_serialize = @context.root_context || @context
42
+ reactor_class_name = context_to_serialize.reactor_class.name
43
+
44
+ serialized_context = ContextSerializer.serialize(context_to_serialize)
45
+
46
+ if @context.map_metadata
47
+ map_args = @context.map_metadata.transform_keys(&:to_sym)
48
+ configuration.async_router.perform_map_element_in(
49
+ delay,
50
+ map_id: map_args[:map_id],
51
+ element_id: map_args[:element_id],
52
+ index: map_args[:index],
53
+ serialized_inputs: map_args[:serialized_inputs],
54
+ reactor_class_info: map_args[:reactor_class_info],
55
+ strict_ordering: map_args[:strict_ordering],
56
+ parent_context_id: map_args[:parent_context_id],
57
+ parent_reactor_class_name: map_args[:parent_reactor_class_name],
58
+ step_name: map_args[:step_name],
59
+ batch_size: map_args[:batch_size],
60
+ serialized_context: serialized_context
61
+ )
62
+ else
63
+ configuration.async_router.perform_in(delay, serialized_context, reactor_class_name)
64
+ end
65
+ end
66
+
67
+ def clear_retry_state
68
+ @context.retry_context.current_step = nil
69
+ @context.retry_context.failure_reason = nil
70
+ @context.retry_context.next_retry_at = nil
71
+ end
72
+
73
+ def prepare_retry_attempt(step_config)
74
+ @context.retry_context.current_step = step_config.name
75
+ @context.retry_context.increment_attempt_for_step(step_config.name)
76
+ end
77
+
78
+ def handle_retry_result(step_config, reactor_class, result)
79
+ case result
80
+ when RubyReactor::Success
81
+ clear_retry_state
82
+ result
83
+ when RubyReactor::Failure
84
+ handle_failure_result(step_config, reactor_class, result)
85
+ when RetryQueuedResult, RubyReactor::AsyncResult
86
+ # Pass through async results
87
+ result
88
+ else
89
+ clear_retry_state
90
+ RubyReactor::Failure("Step '#{step_config.name}' returned unexpected result: #{result.inspect}")
91
+ end
92
+ end
93
+
94
+ def handle_failure_result(step_config, reactor_class, result)
95
+ if can_retry_step?(step_config) && result.retryable?
96
+ handle_retryable_failure(step_config, reactor_class, result)
97
+ else
98
+ handle_non_retryable_failure(step_config, result, reactor_class)
99
+ end
100
+ end
101
+
102
+ def handle_retryable_failure(step_config, reactor_class, result)
103
+ # Check if we should requeue (async retry)
104
+ is_async = reactor_class.async? || step_config.async? ||
105
+ @context.root_context&.reactor_class&.async? ||
106
+ @context.inline_async_execution
107
+
108
+ if is_async && !@context.test_mode
109
+ handle_async_retry(step_config, reactor_class, result)
110
+ else
111
+ handle_sync_retry(step_config, reactor_class, result)
112
+ end
113
+ end
114
+
115
+ def handle_async_retry(step_config, reactor_class, result)
116
+ requeue_job_for_step_retry(step_config, result.error, reactor_class)
117
+ RetryQueuedResult.new(
118
+ step_config.name,
119
+ @context.retry_context.attempts_for_step(step_config.name),
120
+ @context.retry_context.next_retry_at
121
+ )
122
+ end
123
+
124
+ def handle_sync_retry(step_config, reactor_class, result)
125
+ delay = calculate_backoff_delay(step_config, result.error, reactor_class)
126
+ sleep(delay)
127
+ nil # continue loop
128
+ end
129
+
130
+ def handle_non_retryable_failure(step_config, result, reactor_class)
131
+ clear_retry_state
132
+ current_attempts = @context.retry_context.attempts_for_step(step_config.name)
133
+ error_message = result.error.respond_to?(:message) ? result.error.message : result.error.to_s
134
+ MaxRetriesExhaustedFailure.new(
135
+ "Step '#{step_config.name}' failed after #{current_attempts} attempts: #{error_message}",
136
+ step: step_config.name,
137
+ attempts: current_attempts,
138
+ original_error: result.error,
139
+ inputs: result.respond_to?(:inputs) ? result.inputs : {},
140
+ backtrace: result.respond_to?(:backtrace) ? result.backtrace : nil,
141
+ redact_inputs: if result.respond_to?(:instance_variable_get)
142
+ result.instance_variable_get(:@redact_inputs)
143
+ else
144
+ []
145
+ end,
146
+ reactor_name: reactor_class.name,
147
+ step_arguments: result.respond_to?(:step_arguments) ? result.step_arguments : {}
148
+ )
149
+ end
150
+
151
+ def configuration
152
+ RubyReactor::Configuration.instance
153
+ end
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,319 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ class Executor
5
+ class StepExecutor
6
+ def initialize(context:, dependency_graph:, reactor_class:, managers:)
7
+ @context = context
8
+ @dependency_graph = dependency_graph
9
+ @reactor_class = reactor_class
10
+ @retry_manager = managers[:retry_manager]
11
+ @result_handler = managers[:result_handler]
12
+ @compensation_manager = managers[:compensation_manager]
13
+ end
14
+
15
+ def execute_all_steps
16
+ until @dependency_graph.all_completed?
17
+ ready_steps = @dependency_graph.ready_steps
18
+
19
+ if ready_steps.empty?
20
+ raise Error::DependencyError.new(
21
+ "No ready steps available but execution not complete",
22
+ context: @context
23
+ )
24
+ end
25
+
26
+ # Execute steps sequentially
27
+ ready_steps.each do |step_config|
28
+ result = execute_step(step_config)
29
+
30
+ # If step execution was handed off to async, return the async result
31
+ return result if result.is_a?(RubyReactor::AsyncResult)
32
+
33
+ # If a step returns RetryQueuedResult, we need to stop and return it
34
+ return result if result.is_a?(RetryQueuedResult)
35
+
36
+ # If a step returns Failure, we need to stop execution and return it
37
+ return result if result.is_a?(RubyReactor::Failure)
38
+
39
+ # If result is nil, it means async was executed inline (test mode), continue
40
+ next if result.nil?
41
+ end
42
+ end
43
+
44
+ # Return the final result
45
+ @result_handler.final_result(@reactor_class)
46
+ end
47
+
48
+ def execute_step(step_config)
49
+ # If we're already in inline async execution mode (inside Worker),
50
+ # treat async steps as sync to avoid infinite recursion
51
+
52
+ if step_config.async? && !@context.inline_async_execution
53
+ handle_async_step(step_config)
54
+ else
55
+ execute_step_with_retry(step_config)
56
+ end
57
+ end
58
+
59
+ def merge_executor_state(other_executor)
60
+ # Merge the state from the async-executed executor back into ours
61
+ # We need to update our context IN PLACE, not replace the reference,
62
+ # because the Executor also holds a reference to the same context object
63
+
64
+ # Update intermediate results
65
+ other_executor.context.intermediate_results.each do |step_name, value|
66
+ @context.set_result(step_name, value)
67
+ end
68
+
69
+ # Append execution trace from the async execution
70
+ # The Worker's execution will have ALL steps including ones we already executed,
71
+ # but we only want to add the NEW entries (from current_step onwards)
72
+ current_trace_length = @context.execution_trace.length
73
+ new_trace_entries = other_executor.context.execution_trace[current_trace_length..] || []
74
+
75
+ @context.execution_trace.concat(new_trace_entries)
76
+
77
+ # Update retry context
78
+ @context.retry_context = other_executor.context.retry_context
79
+
80
+ # Clear current_step since we've completed it
81
+ @context.current_step = nil
82
+
83
+ # Update our dependency graph to reflect completed steps
84
+ other_executor.context.intermediate_results.each_key do |step_name|
85
+ @dependency_graph.complete_step(step_name)
86
+ end
87
+
88
+ # Also mark the current_step as completed if it exists (for failed steps that don't have results)
89
+ @dependency_graph.complete_step(other_executor.context.current_step) if other_executor.context.current_step
90
+
91
+ # Merge any undo stack items
92
+ other_executor.undo_stack.each do |item|
93
+ # Avoid duplicates by checking if this step is already in the undo stack
94
+ unless @compensation_manager.undo_stack.any? { |existing| existing[:step].name == item[:step].name }
95
+ @compensation_manager.add_to_undo_stack(item)
96
+ end
97
+ end
98
+
99
+ # Merge undo trace from the other executor
100
+ other_executor.undo_trace.each do |trace_entry|
101
+ @compensation_manager.undo_trace << trace_entry
102
+ end
103
+ end
104
+
105
+ def execute_step_with_retry(step_config)
106
+ result = @retry_manager.execute_with_retry(step_config, @reactor_class) do
107
+ safe_execute_step_sync(step_config)
108
+ end
109
+
110
+ unless result.is_a?(RetryQueuedResult) || result.is_a?(RubyReactor::AsyncResult)
111
+ resolved_arguments = resolve_arguments(step_config)
112
+ @result_handler.handle_step_result(step_config, result, resolved_arguments)
113
+ end
114
+
115
+ result
116
+ end
117
+
118
+ def safe_execute_step_sync(step_config)
119
+ resolved_arguments = {}
120
+ execute_step_sync_without_result_handling(step_config) do |args|
121
+ resolved_arguments = args
122
+ end
123
+ rescue StandardError => e
124
+ # Identify redacted inputs
125
+ redact_inputs = @reactor_class.inputs.select { |_, config| config[:redact] }.keys
126
+
127
+ RubyReactor::Failure(
128
+ e,
129
+ step_name: step_config.name,
130
+ inputs: @context.inputs,
131
+ redact_inputs: redact_inputs,
132
+ reactor_name: @reactor_class.name,
133
+ step_arguments: resolved_arguments
134
+ )
135
+ end
136
+
137
+ def execute_step_sync(step_config)
138
+ @context.with_step(step_config.name) do
139
+ # Check conditions and guards
140
+ unless step_config.should_run?(@context)
141
+ @dependency_graph.complete_step(step_config.name)
142
+ return RubyReactor.Success(nil)
143
+ end
144
+
145
+ # Resolve arguments
146
+ resolved_arguments = resolve_arguments(step_config)
147
+
148
+ # Validate arguments if validator is defined
149
+ validate_step_arguments(step_config, resolved_arguments)
150
+
151
+ # Execute the step
152
+ result = run_step_implementation(step_config, resolved_arguments)
153
+
154
+ # Handle the result
155
+ @result_handler.handle_step_result(step_config, result, resolved_arguments)
156
+ end
157
+ end
158
+
159
+ # Execute step without handling the result (used during retries)
160
+ def execute_step_sync_without_result_handling(step_config)
161
+ @context.with_step(step_config.name) do
162
+ # Check conditions and guards
163
+ unless step_config.should_run?(@context)
164
+ @dependency_graph.complete_step(step_config.name)
165
+ return RubyReactor.Success(nil)
166
+ end
167
+
168
+ # Resolve arguments
169
+ resolved_arguments = resolve_arguments(step_config)
170
+
171
+ yield resolved_arguments if block_given?
172
+
173
+ # Validate arguments if validator is defined
174
+ validate_step_arguments(step_config, resolved_arguments)
175
+
176
+ # Execute the step
177
+ run_step_implementation(step_config, resolved_arguments)
178
+ end
179
+ end
180
+
181
+ private
182
+
183
+ def handle_async_step(step_config)
184
+ # Step-level async: hand off execution to worker
185
+
186
+ @context.current_step = step_config.name
187
+ @context.undo_stack = @compensation_manager.undo_stack
188
+
189
+ # Use root context if available to ensure we serialize the full tree
190
+ context_to_serialize = @context.root_context || @context
191
+ reactor_class_name = context_to_serialize.reactor_class.name
192
+
193
+ serialized_context = ContextSerializer.serialize(context_to_serialize)
194
+
195
+ result = configuration.async_router.perform_async(
196
+ serialized_context,
197
+ reactor_class_name,
198
+ intermediate_results: @context.intermediate_results
199
+ )
200
+
201
+ # Handle different result types from async router
202
+ case result
203
+ when RubyReactor::AsyncResult
204
+ # Production behavior: return async result to caller
205
+
206
+ result
207
+ when Executor
208
+ handle_inline_executor_result(result)
209
+ else
210
+ # Unexpected result type, treat as error
211
+ raise Error::ValidationError.new(
212
+ "Unexpected result type from async router: #{result.class}",
213
+ context: @context
214
+ )
215
+ end
216
+ end
217
+
218
+ def handle_inline_executor_result(result)
219
+ # Worker executed inline and returned an executor.
220
+ # This happens when running in test mode or when perform_async returns an executor.
221
+ # We need to merge the state back into our current executor.
222
+ #
223
+ # If we are a child reactor, the worker executed the root reactor, so the result
224
+ # will be a Root executor. We handle this mismatch below by finding our
225
+ # corresponding child context within the root result.
226
+ if @context.root_context && (result.context.reactor_class != @reactor_class)
227
+ # We are a child, and result is root.
228
+ # We need to find ourselves in the root result using context_id.
229
+ matching_context = find_context_by_id(result.context, @context.context_id)
230
+
231
+ if matching_context
232
+ # Replace the result's context with the matching child context
233
+ # so merge_executor_state works correctly
234
+ result.instance_variable_set(:@context, matching_context)
235
+ else
236
+ # Fallback: if we can't find it (shouldn't happen), we might be in trouble.
237
+ # But let's try to proceed, maybe it's not nested?
238
+ # For now, raise an error to be explicit
239
+ raise Error::ValidationError.new(
240
+ "Could not find child context with ID #{@context.context_id} in root result",
241
+ context: @context
242
+ )
243
+ end
244
+ end
245
+
246
+ merge_executor_state(result)
247
+
248
+ result.result
249
+ end
250
+
251
+ def configuration
252
+ RubyReactor::Configuration.instance
253
+ end
254
+
255
+ def validate_step_arguments(step_config, resolved_arguments)
256
+ return unless step_config.args_validator
257
+
258
+ validation_result = step_config.args_validator.call(resolved_arguments)
259
+ return if validation_result.success?
260
+
261
+ raise Error::StepFailureError.new(
262
+ "Step '#{step_config.name}' argument validation failed: #{validation_result.error.message}",
263
+ step: step_config.name,
264
+ context: @context
265
+ )
266
+ end
267
+
268
+ def resolve_arguments(step_config)
269
+ resolved = {}
270
+
271
+ step_config.arguments.each do |arg_name, arg_config|
272
+ source = arg_config[:source]
273
+ transform = arg_config[:transform]
274
+
275
+ value = source.resolve(@context)
276
+ value = transform.call(value) if transform
277
+
278
+ resolved[arg_name] = value
279
+ end
280
+
281
+ resolved
282
+ end
283
+
284
+ def run_step_implementation(step_config, arguments)
285
+ @context.execution_trace << { type: :run, step: step_config.name, timestamp: Time.now, arguments: arguments }
286
+ if step_config.has_run_block?
287
+ # Execute inline block
288
+ # If no arguments are defined for the step, pass the reactor inputs as arguments
289
+ args_to_pass = arguments.empty? ? @context.inputs : arguments
290
+ step_config.run_block.call(args_to_pass, @context)
291
+ elsif step_config.has_impl?
292
+ # Execute step class
293
+ step_config.impl.run(arguments, @context)
294
+ else
295
+ raise Error::ValidationError.new(
296
+ "Step '#{step_config.name}' has no implementation",
297
+ step: step_config.name,
298
+ context: @context
299
+ )
300
+ end
301
+ end
302
+
303
+ def find_context_by_id(root_context, target_id)
304
+ return root_context if root_context.context_id == target_id
305
+
306
+ # Search in composed contexts
307
+ root_context.composed_contexts.each_value do |composed_data|
308
+ # composed_data is a hash with :context key
309
+ next unless composed_data.is_a?(Hash) && composed_data[:context].is_a?(RubyReactor::Context)
310
+
311
+ found = find_context_by_id(composed_data[:context], target_id)
312
+ return found if found
313
+ end
314
+
315
+ nil
316
+ end
317
+ end
318
+ end
319
+ end