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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +98 -0
- data/CODE_OF_CONDUCT.md +84 -0
- data/README.md +570 -0
- data/Rakefile +12 -0
- data/documentation/DAG.md +457 -0
- data/documentation/README.md +123 -0
- data/documentation/async_reactors.md +369 -0
- data/documentation/composition.md +199 -0
- data/documentation/core_concepts.md +662 -0
- data/documentation/data_pipelines.md +224 -0
- data/documentation/examples/inventory_management.md +749 -0
- data/documentation/examples/order_processing.md +365 -0
- data/documentation/examples/payment_processing.md +654 -0
- data/documentation/getting_started.md +224 -0
- data/documentation/retry_configuration.md +357 -0
- data/lib/ruby_reactor/async_router.rb +91 -0
- data/lib/ruby_reactor/configuration.rb +41 -0
- data/lib/ruby_reactor/context.rb +169 -0
- data/lib/ruby_reactor/context_serializer.rb +164 -0
- data/lib/ruby_reactor/dependency_graph.rb +126 -0
- data/lib/ruby_reactor/dsl/compose_builder.rb +86 -0
- data/lib/ruby_reactor/dsl/map_builder.rb +112 -0
- data/lib/ruby_reactor/dsl/reactor.rb +151 -0
- data/lib/ruby_reactor/dsl/step_builder.rb +177 -0
- data/lib/ruby_reactor/dsl/template_helpers.rb +36 -0
- data/lib/ruby_reactor/dsl/validation_helpers.rb +35 -0
- data/lib/ruby_reactor/error/base.rb +16 -0
- data/lib/ruby_reactor/error/compensation_error.rb +8 -0
- data/lib/ruby_reactor/error/context_too_large_error.rb +11 -0
- data/lib/ruby_reactor/error/dependency_error.rb +8 -0
- data/lib/ruby_reactor/error/deserialization_error.rb +11 -0
- data/lib/ruby_reactor/error/input_validation_error.rb +29 -0
- data/lib/ruby_reactor/error/schema_version_error.rb +11 -0
- data/lib/ruby_reactor/error/step_failure_error.rb +18 -0
- data/lib/ruby_reactor/error/undo_error.rb +8 -0
- data/lib/ruby_reactor/error/validation_error.rb +8 -0
- data/lib/ruby_reactor/executor/compensation_manager.rb +79 -0
- data/lib/ruby_reactor/executor/graph_manager.rb +41 -0
- data/lib/ruby_reactor/executor/input_validator.rb +39 -0
- data/lib/ruby_reactor/executor/result_handler.rb +103 -0
- data/lib/ruby_reactor/executor/retry_manager.rb +156 -0
- data/lib/ruby_reactor/executor/step_executor.rb +319 -0
- data/lib/ruby_reactor/executor.rb +123 -0
- data/lib/ruby_reactor/map/collector.rb +65 -0
- data/lib/ruby_reactor/map/element_executor.rb +154 -0
- data/lib/ruby_reactor/map/execution.rb +60 -0
- data/lib/ruby_reactor/map/helpers.rb +67 -0
- data/lib/ruby_reactor/max_retries_exhausted_failure.rb +19 -0
- data/lib/ruby_reactor/reactor.rb +75 -0
- data/lib/ruby_reactor/retry_context.rb +92 -0
- data/lib/ruby_reactor/retry_queued_result.rb +26 -0
- data/lib/ruby_reactor/sidekiq_workers/map_collector_worker.rb +13 -0
- data/lib/ruby_reactor/sidekiq_workers/map_element_worker.rb +13 -0
- data/lib/ruby_reactor/sidekiq_workers/map_execution_worker.rb +15 -0
- data/lib/ruby_reactor/sidekiq_workers/worker.rb +55 -0
- data/lib/ruby_reactor/step/compose_step.rb +107 -0
- data/lib/ruby_reactor/step/map_step.rb +234 -0
- data/lib/ruby_reactor/step.rb +33 -0
- data/lib/ruby_reactor/storage/adapter.rb +51 -0
- data/lib/ruby_reactor/storage/configuration.rb +15 -0
- data/lib/ruby_reactor/storage/redis_adapter.rb +140 -0
- data/lib/ruby_reactor/template/base.rb +15 -0
- data/lib/ruby_reactor/template/element.rb +25 -0
- data/lib/ruby_reactor/template/input.rb +48 -0
- data/lib/ruby_reactor/template/result.rb +48 -0
- data/lib/ruby_reactor/template/value.rb +22 -0
- data/lib/ruby_reactor/validation/base.rb +26 -0
- data/lib/ruby_reactor/validation/input_validator.rb +62 -0
- data/lib/ruby_reactor/validation/schema_builder.rb +17 -0
- data/lib/ruby_reactor/version.rb +5 -0
- data/lib/ruby_reactor.rb +159 -0
- data/sig/ruby_reactor.rbs +4 -0
- 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
|