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,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "executor/input_validator"
|
|
4
|
+
require_relative "executor/graph_manager"
|
|
5
|
+
require_relative "executor/retry_manager"
|
|
6
|
+
require_relative "executor/compensation_manager"
|
|
7
|
+
require_relative "executor/result_handler"
|
|
8
|
+
require_relative "executor/step_executor"
|
|
9
|
+
|
|
10
|
+
module RubyReactor
|
|
11
|
+
class Executor
|
|
12
|
+
attr_reader :reactor_class, :context, :dependency_graph, :compensation_manager, :retry_manager, :result_handler,
|
|
13
|
+
:step_executor, :result
|
|
14
|
+
|
|
15
|
+
def initialize(reactor_class, inputs = {}, context = nil)
|
|
16
|
+
@reactor_class = reactor_class
|
|
17
|
+
@context = context || Context.new(inputs, reactor_class)
|
|
18
|
+
@dependency_graph = DependencyGraph.new
|
|
19
|
+
@compensation_manager = CompensationManager.new(@context)
|
|
20
|
+
@retry_manager = RetryManager.new(@context)
|
|
21
|
+
@result_handler = ResultHandler.new(
|
|
22
|
+
context: @context,
|
|
23
|
+
compensation_manager: @compensation_manager,
|
|
24
|
+
dependency_graph: @dependency_graph
|
|
25
|
+
)
|
|
26
|
+
@step_executor = StepExecutor.new(
|
|
27
|
+
context: @context,
|
|
28
|
+
dependency_graph: @dependency_graph,
|
|
29
|
+
reactor_class: @reactor_class,
|
|
30
|
+
managers: {
|
|
31
|
+
retry_manager: @retry_manager,
|
|
32
|
+
result_handler: @result_handler,
|
|
33
|
+
compensation_manager: @compensation_manager
|
|
34
|
+
}
|
|
35
|
+
)
|
|
36
|
+
@result = nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def execute
|
|
40
|
+
input_validator = InputValidator.new(@reactor_class, @context)
|
|
41
|
+
input_validator.validate!
|
|
42
|
+
|
|
43
|
+
graph_manager = GraphManager.new(@reactor_class, @dependency_graph, @context)
|
|
44
|
+
graph_manager.build_and_validate!
|
|
45
|
+
|
|
46
|
+
@result = @step_executor.execute_all_steps
|
|
47
|
+
rescue StandardError => e
|
|
48
|
+
@result = @result_handler.handle_execution_error(e)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def resume_execution
|
|
52
|
+
prepare_for_resume
|
|
53
|
+
|
|
54
|
+
if @context.current_step
|
|
55
|
+
execute_current_step_and_continue
|
|
56
|
+
else
|
|
57
|
+
execute_remaining_steps
|
|
58
|
+
end
|
|
59
|
+
rescue StandardError => e
|
|
60
|
+
handle_resume_error(e)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def undo_stack
|
|
64
|
+
@compensation_manager.undo_stack
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def undo_trace
|
|
68
|
+
@compensation_manager.undo_trace
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def execution_trace
|
|
72
|
+
@context.execution_trace
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def prepare_for_resume
|
|
78
|
+
# Build dependency graph and mark completed steps
|
|
79
|
+
graph_manager = GraphManager.new(@reactor_class, @dependency_graph, @context)
|
|
80
|
+
graph_manager.build_and_validate!
|
|
81
|
+
graph_manager.mark_completed_steps_from_context
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def execute_current_step_and_continue
|
|
85
|
+
step_config = @reactor_class.steps[@context.current_step]
|
|
86
|
+
return RubyReactor::Failure("Step '#{@context.current_step}' not found in reactor") unless step_config
|
|
87
|
+
|
|
88
|
+
# Use execute_step (not execute_step_with_retry) so that async steps can be handled properly in inline mode
|
|
89
|
+
result = @step_executor.execute_step(step_config)
|
|
90
|
+
|
|
91
|
+
# execute_step returns nil for inline async, meaning continue execution
|
|
92
|
+
if result.nil?
|
|
93
|
+
@result = @step_executor.execute_all_steps
|
|
94
|
+
else
|
|
95
|
+
case result
|
|
96
|
+
when RetryQueuedResult, RubyReactor::Failure, RubyReactor::AsyncResult
|
|
97
|
+
# Step was requeued, failed, or handed off to async - return the result
|
|
98
|
+
@result = result
|
|
99
|
+
when RubyReactor::Success
|
|
100
|
+
# Step succeeded, continue with remaining steps
|
|
101
|
+
@result = @step_executor.execute_all_steps
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
@result
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def execute_remaining_steps
|
|
108
|
+
@result = @step_executor.execute_all_steps
|
|
109
|
+
@result
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def handle_resume_error(error)
|
|
113
|
+
# Only handle errors that haven't already triggered compensation
|
|
114
|
+
# StepFailureError means compensation already happened, just convert to Failure
|
|
115
|
+
@result = if error.is_a?(Error::StepFailureError)
|
|
116
|
+
RubyReactor.Failure(error.message)
|
|
117
|
+
else
|
|
118
|
+
@result_handler.handle_execution_error(error)
|
|
119
|
+
end
|
|
120
|
+
@result
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
module Map
|
|
5
|
+
class Collector
|
|
6
|
+
extend Helpers
|
|
7
|
+
|
|
8
|
+
# rubocop:disable Metrics/MethodLength
|
|
9
|
+
def self.perform(arguments)
|
|
10
|
+
arguments = arguments.transform_keys(&:to_sym)
|
|
11
|
+
parent_context_id = arguments[:parent_context_id]
|
|
12
|
+
map_id = arguments[:map_id]
|
|
13
|
+
parent_reactor_class_name = arguments[:parent_reactor_class_name]
|
|
14
|
+
step_name = arguments[:step_name]
|
|
15
|
+
strict_ordering = arguments[:strict_ordering]
|
|
16
|
+
|
|
17
|
+
storage = RubyReactor.configuration.storage_adapter
|
|
18
|
+
|
|
19
|
+
# Retrieve parent context
|
|
20
|
+
parent_context = load_parent_context_from_storage(
|
|
21
|
+
parent_context_id,
|
|
22
|
+
parent_reactor_class_name,
|
|
23
|
+
storage
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# Retrieve results
|
|
27
|
+
serialized_results = storage.retrieve_map_results(map_id, parent_reactor_class_name,
|
|
28
|
+
strict_ordering: strict_ordering)
|
|
29
|
+
|
|
30
|
+
results = serialized_results.map do |r|
|
|
31
|
+
if r.is_a?(Hash) && r.key?("_error")
|
|
32
|
+
RubyReactor::Failure(r["_error"])
|
|
33
|
+
else
|
|
34
|
+
RubyReactor::Success(r)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Get step config to check for collect block and other options
|
|
39
|
+
parent_class = Object.const_get(parent_reactor_class_name)
|
|
40
|
+
step_config = parent_class.steps[step_name.to_sym]
|
|
41
|
+
|
|
42
|
+
collect_block = step_config.arguments[:collect_block][:source].value
|
|
43
|
+
# TODO: Check allow_partial_failure option
|
|
44
|
+
|
|
45
|
+
final_result = if collect_block
|
|
46
|
+
begin
|
|
47
|
+
# Pass all results (Success and Failure) to collect block
|
|
48
|
+
collected = collect_block.call(results)
|
|
49
|
+
RubyReactor::Success(collected)
|
|
50
|
+
rescue StandardError => e
|
|
51
|
+
RubyReactor::Failure(e)
|
|
52
|
+
end
|
|
53
|
+
else
|
|
54
|
+
# Default behavior: fail if any failure
|
|
55
|
+
first_failure = results.find(&:failure?)
|
|
56
|
+
first_failure || RubyReactor::Success(results.map(&:value))
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Resume execution
|
|
60
|
+
resume_parent_execution(parent_context, step_name, final_result, storage)
|
|
61
|
+
end
|
|
62
|
+
# rubocop:enable Metrics/MethodLength
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
module Map
|
|
5
|
+
class ElementExecutor
|
|
6
|
+
extend Helpers
|
|
7
|
+
|
|
8
|
+
# rubocop:disable Metrics/MethodLength
|
|
9
|
+
def self.perform(arguments)
|
|
10
|
+
arguments = arguments.transform_keys(&:to_sym)
|
|
11
|
+
map_id = arguments[:map_id]
|
|
12
|
+
_element_id = arguments[:element_id]
|
|
13
|
+
index = arguments[:index]
|
|
14
|
+
serialized_inputs = arguments[:serialized_inputs]
|
|
15
|
+
reactor_class_info = arguments[:reactor_class_info]
|
|
16
|
+
strict_ordering = arguments[:strict_ordering]
|
|
17
|
+
parent_context_id = arguments[:parent_context_id]
|
|
18
|
+
parent_reactor_class_name = arguments[:parent_reactor_class_name]
|
|
19
|
+
step_name = arguments[:step_name]
|
|
20
|
+
batch_size = arguments[:batch_size]
|
|
21
|
+
# rubocop:enable Metrics/MethodLength
|
|
22
|
+
serialized_context = arguments[:serialized_context]
|
|
23
|
+
|
|
24
|
+
if serialized_context
|
|
25
|
+
context = ContextSerializer.deserialize(serialized_context)
|
|
26
|
+
context.map_metadata = arguments
|
|
27
|
+
reactor_class = context.reactor_class
|
|
28
|
+
else
|
|
29
|
+
# Deserialize inputs
|
|
30
|
+
inputs = ContextSerializer.deserialize_value(serialized_inputs)
|
|
31
|
+
|
|
32
|
+
# Resolve reactor class
|
|
33
|
+
reactor_class = resolve_reactor_class(reactor_class_info)
|
|
34
|
+
|
|
35
|
+
# Create context
|
|
36
|
+
context = Context.new(inputs, reactor_class)
|
|
37
|
+
context.map_metadata = arguments
|
|
38
|
+
end
|
|
39
|
+
storage = RubyReactor.configuration.storage_adapter
|
|
40
|
+
|
|
41
|
+
# Execute
|
|
42
|
+
executor = Executor.new(reactor_class, {}, context)
|
|
43
|
+
|
|
44
|
+
if serialized_context
|
|
45
|
+
executor.resume_execution
|
|
46
|
+
else
|
|
47
|
+
executor.execute
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
result = executor.result
|
|
51
|
+
|
|
52
|
+
if result.is_a?(RetryQueuedResult)
|
|
53
|
+
queue_next_batch(arguments) if batch_size
|
|
54
|
+
return
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Store result
|
|
58
|
+
|
|
59
|
+
# Store result
|
|
60
|
+
|
|
61
|
+
if result.success?
|
|
62
|
+
storage.store_map_result(map_id, index, result.value, parent_reactor_class_name,
|
|
63
|
+
strict_ordering: strict_ordering)
|
|
64
|
+
else
|
|
65
|
+
# Store error
|
|
66
|
+
storage.store_map_result(map_id, index, { _error: result.error }, parent_reactor_class_name,
|
|
67
|
+
strict_ordering: strict_ordering)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Decrement counter
|
|
71
|
+
new_count = storage.decrement_map_counter(map_id, parent_reactor_class_name)
|
|
72
|
+
|
|
73
|
+
queue_next_batch(arguments) if batch_size
|
|
74
|
+
|
|
75
|
+
return unless new_count.zero?
|
|
76
|
+
|
|
77
|
+
# Trigger collection
|
|
78
|
+
RubyReactor.configuration.async_router.perform_map_collection_async(
|
|
79
|
+
parent_context_id: parent_context_id,
|
|
80
|
+
map_id: map_id,
|
|
81
|
+
parent_reactor_class_name: parent_reactor_class_name,
|
|
82
|
+
step_name: step_name,
|
|
83
|
+
strict_ordering: strict_ordering,
|
|
84
|
+
timeout: 3600
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def self.queue_next_batch(arguments)
|
|
89
|
+
storage = RubyReactor.configuration.storage_adapter
|
|
90
|
+
map_id = arguments[:map_id]
|
|
91
|
+
reactor_class_name = arguments[:parent_reactor_class_name]
|
|
92
|
+
|
|
93
|
+
next_index = storage.increment_last_queued_index(map_id, reactor_class_name)
|
|
94
|
+
total_count = storage.retrieve_map_metadata(map_id, reactor_class_name)["count"]
|
|
95
|
+
|
|
96
|
+
return unless next_index < total_count
|
|
97
|
+
|
|
98
|
+
parent_context = load_parent_context(arguments, reactor_class_name, storage)
|
|
99
|
+
element = resolve_next_element(arguments, parent_context, next_index)
|
|
100
|
+
serialized_inputs = build_serialized_inputs(arguments, parent_context, element)
|
|
101
|
+
|
|
102
|
+
queue_element_job(arguments, map_id, next_index, serialized_inputs, reactor_class_name)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def self.load_parent_context(arguments, reactor_class_name, storage)
|
|
106
|
+
parent_context_data = storage.retrieve_context(arguments[:parent_context_id], reactor_class_name)
|
|
107
|
+
parent_reactor_class = Object.const_get(reactor_class_name)
|
|
108
|
+
parent_context = Context.new(
|
|
109
|
+
ContextSerializer.deserialize_value(parent_context_data["inputs"]),
|
|
110
|
+
parent_reactor_class
|
|
111
|
+
)
|
|
112
|
+
parent_context.context_id = arguments[:parent_context_id]
|
|
113
|
+
parent_context
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def self.resolve_next_element(arguments, parent_context, next_index)
|
|
117
|
+
parent_reactor_class = parent_context.reactor_class
|
|
118
|
+
step_config = parent_reactor_class.steps[arguments[:step_name].to_sym]
|
|
119
|
+
|
|
120
|
+
source_template = step_config.arguments[:source][:source]
|
|
121
|
+
source = source_template.resolve(parent_context)
|
|
122
|
+
source[next_index]
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def self.build_serialized_inputs(arguments, parent_context, element)
|
|
126
|
+
parent_reactor_class = parent_context.reactor_class
|
|
127
|
+
step_config = parent_reactor_class.steps[arguments[:step_name].to_sym]
|
|
128
|
+
|
|
129
|
+
mappings_template = step_config.arguments[:argument_mappings][:source]
|
|
130
|
+
mappings = mappings_template.resolve(parent_context) || {}
|
|
131
|
+
|
|
132
|
+
mapped_inputs = build_element_inputs(mappings, parent_context, element)
|
|
133
|
+
ContextSerializer.serialize_value(mapped_inputs)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def self.queue_element_job(arguments, map_id, next_index, serialized_inputs, reactor_class_name)
|
|
137
|
+
RubyReactor.configuration.async_router.perform_map_element_async(
|
|
138
|
+
map_id: map_id,
|
|
139
|
+
element_id: "#{map_id}:#{next_index}",
|
|
140
|
+
index: next_index,
|
|
141
|
+
serialized_inputs: serialized_inputs,
|
|
142
|
+
reactor_class_info: arguments[:reactor_class_info],
|
|
143
|
+
strict_ordering: arguments[:strict_ordering],
|
|
144
|
+
parent_context_id: arguments[:parent_context_id],
|
|
145
|
+
parent_reactor_class_name: reactor_class_name,
|
|
146
|
+
step_name: arguments[:step_name],
|
|
147
|
+
batch_size: arguments[:batch_size]
|
|
148
|
+
)
|
|
149
|
+
end
|
|
150
|
+
private_class_method :queue_next_batch, :load_parent_context,
|
|
151
|
+
:resolve_next_element, :build_serialized_inputs, :queue_element_job
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
module Map
|
|
5
|
+
class Execution
|
|
6
|
+
extend Helpers
|
|
7
|
+
|
|
8
|
+
def self.perform(arguments)
|
|
9
|
+
arguments = arguments.transform_keys(&:to_sym)
|
|
10
|
+
storage = RubyReactor.configuration.storage_adapter
|
|
11
|
+
|
|
12
|
+
parent_context = load_parent_context_from_storage(
|
|
13
|
+
arguments[:parent_context_id], arguments[:parent_reactor_class_name], storage
|
|
14
|
+
)
|
|
15
|
+
reactor_class = resolve_reactor_class(arguments[:reactor_class_info])
|
|
16
|
+
inputs = ContextSerializer.deserialize_value(arguments[:serialized_inputs])
|
|
17
|
+
|
|
18
|
+
results = execute_all_elements(
|
|
19
|
+
source: inputs[:source], mappings: inputs[:mappings],
|
|
20
|
+
reactor_class: reactor_class, parent_context: parent_context,
|
|
21
|
+
storage_options: {
|
|
22
|
+
map_id: arguments[:map_id], storage: storage,
|
|
23
|
+
parent_reactor_class_name: arguments[:parent_reactor_class_name],
|
|
24
|
+
strict_ordering: arguments[:strict_ordering]
|
|
25
|
+
}
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
finalize_execution(results, parent_context, arguments[:step_name], arguments[:parent_reactor_class_name],
|
|
29
|
+
storage)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.execute_all_elements(source:, mappings:, reactor_class:, parent_context:, storage_options:)
|
|
33
|
+
source.map.with_index do |element, index|
|
|
34
|
+
element_inputs = build_element_inputs(mappings, parent_context, element)
|
|
35
|
+
result = reactor_class.run(element_inputs)
|
|
36
|
+
|
|
37
|
+
store_result(result, index, storage_options)
|
|
38
|
+
|
|
39
|
+
result
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.store_result(result, index, options)
|
|
44
|
+
value = result.success? ? result.value : { _error: result.error }
|
|
45
|
+
options[:storage].store_map_result(
|
|
46
|
+
options[:map_id], index, value, options[:parent_reactor_class_name],
|
|
47
|
+
strict_ordering: options[:strict_ordering]
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.finalize_execution(results, parent_context, step_name, parent_reactor_class_name, storage)
|
|
52
|
+
step_config = Object.const_get(parent_reactor_class_name).steps[step_name.to_sym]
|
|
53
|
+
final_result = apply_collect_block(results, step_config)
|
|
54
|
+
resume_parent_execution(parent_context, step_name, final_result, storage)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private_class_method :execute_all_elements, :store_result, :finalize_execution
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
module Map
|
|
5
|
+
# Shared helper methods for Map executors
|
|
6
|
+
module Helpers
|
|
7
|
+
# Resolves the reactor class from reactor_class_info
|
|
8
|
+
def resolve_reactor_class(info)
|
|
9
|
+
if info["type"] == "class"
|
|
10
|
+
Object.const_get(info["name"])
|
|
11
|
+
elsif info["type"] == "inline"
|
|
12
|
+
parent_class = Object.const_get(info["parent"])
|
|
13
|
+
step_config = parent_class.steps[info["step"].to_sym]
|
|
14
|
+
step_config.arguments[:mapped_reactor_class][:source].value
|
|
15
|
+
else
|
|
16
|
+
raise "Unknown reactor class info: #{info}"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Loads parent context from storage
|
|
21
|
+
def load_parent_context_from_storage(parent_context_id, reactor_class_name, storage)
|
|
22
|
+
parent_context_data = storage.retrieve_context(parent_context_id, reactor_class_name)
|
|
23
|
+
RubyReactor::Context.deserialize_from_retry(parent_context_data)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Builds mapped inputs for a single element
|
|
27
|
+
def build_element_inputs(mappings, parent_context, element)
|
|
28
|
+
RubyReactor::Step::MapStep.build_mapped_inputs(mappings, parent_context, element)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Applies collect block to results
|
|
32
|
+
def apply_collect_block(results, step_config)
|
|
33
|
+
collect_block = step_config.arguments[:collect_block][:source].value
|
|
34
|
+
|
|
35
|
+
if collect_block
|
|
36
|
+
# Pass all results (Success and Failure) to collect block
|
|
37
|
+
begin
|
|
38
|
+
collected = collect_block.call(results)
|
|
39
|
+
RubyReactor::Success(collected)
|
|
40
|
+
rescue StandardError => e
|
|
41
|
+
RubyReactor::Failure(e)
|
|
42
|
+
end
|
|
43
|
+
else
|
|
44
|
+
# Default behavior: fail if any failure
|
|
45
|
+
first_failure = results.find(&:failure?)
|
|
46
|
+
first_failure || RubyReactor::Success(results.map(&:value))
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Resumes parent reactor execution after map completion
|
|
51
|
+
def resume_parent_execution(parent_context, step_name, final_result, storage)
|
|
52
|
+
value = final_result.success? ? final_result.value : final_result
|
|
53
|
+
parent_context.set_result(step_name.to_sym, value)
|
|
54
|
+
parent_context.current_step = nil
|
|
55
|
+
|
|
56
|
+
executor = RubyReactor::Executor.new(parent_context.reactor_class, {}, parent_context)
|
|
57
|
+
executor.resume_execution
|
|
58
|
+
|
|
59
|
+
storage.store_context(
|
|
60
|
+
parent_context.context_id,
|
|
61
|
+
ContextSerializer.serialize(parent_context),
|
|
62
|
+
parent_context.reactor_class.name
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
class MaxRetriesExhaustedFailure < Failure
|
|
5
|
+
attr_reader :attempts, :original_error
|
|
6
|
+
|
|
7
|
+
# rubocop:disable Metrics/ParameterLists
|
|
8
|
+
def initialize(message, step:, attempts:, original_error: nil,
|
|
9
|
+
inputs: {}, backtrace: nil, redact_inputs: [],
|
|
10
|
+
reactor_name: nil, step_arguments: {})
|
|
11
|
+
# rubocop:enable Metrics/ParameterLists
|
|
12
|
+
super(message,
|
|
13
|
+
step_name: step, inputs: inputs, backtrace: backtrace,
|
|
14
|
+
redact_inputs: redact_inputs, reactor_name: reactor_name, step_arguments: step_arguments)
|
|
15
|
+
@attempts = attempts
|
|
16
|
+
@original_error = original_error
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
class Reactor
|
|
5
|
+
include RubyReactor::Dsl::Reactor
|
|
6
|
+
|
|
7
|
+
attr_reader :context, :result, :undo_trace, :execution_trace
|
|
8
|
+
|
|
9
|
+
def initialize(context = {})
|
|
10
|
+
@context = context
|
|
11
|
+
@result = :unexecuted
|
|
12
|
+
@undo_trace = []
|
|
13
|
+
@execution_trace = []
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def run(inputs = {})
|
|
17
|
+
if self.class.async?
|
|
18
|
+
# For async reactors, enqueue the job and return immediately
|
|
19
|
+
context = Context.new(inputs, self.class)
|
|
20
|
+
serialized_context = ContextSerializer.serialize(context)
|
|
21
|
+
configuration.async_router.perform_async(serialized_context)
|
|
22
|
+
else
|
|
23
|
+
# For sync reactors (potentially with async steps), execute normally
|
|
24
|
+
executor = Executor.new(self.class, inputs)
|
|
25
|
+
@result = executor.execute
|
|
26
|
+
|
|
27
|
+
@context = executor.context
|
|
28
|
+
|
|
29
|
+
@undo_trace = executor.undo_trace
|
|
30
|
+
@execution_trace = executor.execution_trace
|
|
31
|
+
|
|
32
|
+
# If execution returned an AsyncResult (from step-level async), return it
|
|
33
|
+
return @result if @result.is_a?(RubyReactor::AsyncResult)
|
|
34
|
+
|
|
35
|
+
@result
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def validate!
|
|
40
|
+
# Validate reactor configuration
|
|
41
|
+
validate_steps!
|
|
42
|
+
validate_return_step!
|
|
43
|
+
validate_dependencies!
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def configuration
|
|
49
|
+
RubyReactor::Configuration.instance
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def validate_steps!
|
|
53
|
+
return unless self.class.steps.empty?
|
|
54
|
+
|
|
55
|
+
raise Error::ValidationError, "Reactor must have at least one step"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def validate_return_step!
|
|
59
|
+
return unless self.class.return_step
|
|
60
|
+
|
|
61
|
+
return if self.class.steps.key?(self.class.return_step)
|
|
62
|
+
|
|
63
|
+
raise Error::ValidationError, "Return step '#{self.class.return_step}' is not defined"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def validate_dependencies!
|
|
67
|
+
graph = DependencyGraph.new
|
|
68
|
+
self.class.steps.each_value { |config| graph.add_step(config) }
|
|
69
|
+
|
|
70
|
+
return unless graph.has_cycles?
|
|
71
|
+
|
|
72
|
+
raise Error::DependencyError, "Dependency graph contains cycles"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
# Tracks retry attempts and state for steps in async execution
|
|
5
|
+
class RetryContext
|
|
6
|
+
attr_accessor :step_attempts, :current_step, :failure_reason, :next_retry_at
|
|
7
|
+
|
|
8
|
+
def initialize
|
|
9
|
+
@step_attempts = {}
|
|
10
|
+
@current_step = nil
|
|
11
|
+
@failure_reason = nil
|
|
12
|
+
@next_retry_at = nil
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def increment_attempt_for_step(step_name)
|
|
16
|
+
step_name = step_name.to_s
|
|
17
|
+
@step_attempts[step_name] ||= 0
|
|
18
|
+
@step_attempts[step_name] += 1
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def attempts_for_step(step_name)
|
|
22
|
+
step_name = step_name.to_s
|
|
23
|
+
@step_attempts[step_name] || 0
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def can_retry_step?(step_name, max_attempts)
|
|
27
|
+
attempts = attempts_for_step(step_name)
|
|
28
|
+
attempts < max_attempts
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def reset
|
|
32
|
+
@step_attempts = {}
|
|
33
|
+
@current_step = nil
|
|
34
|
+
@failure_reason = nil
|
|
35
|
+
@next_retry_at = nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def serialize_for_retry
|
|
39
|
+
{
|
|
40
|
+
step_attempts: @step_attempts,
|
|
41
|
+
current_step: @current_step,
|
|
42
|
+
failure_reason: serialize_error(@failure_reason),
|
|
43
|
+
next_retry_at: @next_retry_at&.iso8601
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.deserialize_from_retry(data)
|
|
48
|
+
context = new
|
|
49
|
+
context.step_attempts = data["step_attempts"] || {}
|
|
50
|
+
context.current_step = data["current_step"]
|
|
51
|
+
context.failure_reason = deserialize_error(data["failure_reason"])
|
|
52
|
+
context.next_retry_at = data["next_retry_at"] ? Time.iso8601(data["next_retry_at"]) : nil
|
|
53
|
+
context
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.calculate_backoff_delay(attempt_number, backoff_strategy, base_delay)
|
|
57
|
+
case backoff_strategy
|
|
58
|
+
when :exponential
|
|
59
|
+
base_delay * (2**(attempt_number - 1))
|
|
60
|
+
when :linear
|
|
61
|
+
base_delay * attempt_number
|
|
62
|
+
when :fixed
|
|
63
|
+
base_delay
|
|
64
|
+
else
|
|
65
|
+
raise ArgumentError, "Unknown backoff strategy: #{backoff_strategy}"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def serialize_error(error)
|
|
72
|
+
return nil unless error
|
|
73
|
+
|
|
74
|
+
{
|
|
75
|
+
class: error.class.name,
|
|
76
|
+
message: error.message,
|
|
77
|
+
backtrace: error.backtrace
|
|
78
|
+
}
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def self.deserialize_error(data)
|
|
82
|
+
return nil unless data
|
|
83
|
+
|
|
84
|
+
error_class = data["class"] ? Object.const_get(data["class"]) : StandardError
|
|
85
|
+
error = error_class.new(data["message"])
|
|
86
|
+
error.set_backtrace(data["backtrace"]) if data["backtrace"]
|
|
87
|
+
error
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private_class_method :deserialize_error
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
# Result returned when a step retry has been queued for async execution
|
|
5
|
+
class RetryQueuedResult
|
|
6
|
+
attr_reader :step_name, :attempt_number, :next_retry_at
|
|
7
|
+
|
|
8
|
+
def initialize(step_name, attempt_number, next_retry_at)
|
|
9
|
+
@step_name = step_name
|
|
10
|
+
@attempt_number = attempt_number
|
|
11
|
+
@next_retry_at = next_retry_at
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def retry_queued?
|
|
15
|
+
true
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def success?
|
|
19
|
+
false
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def failure?
|
|
23
|
+
false
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|