ruby_reactor 0.1.0 → 0.2.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 +4 -4
- data/.rubocop.yml +10 -2
- data/README.md +72 -3
- data/Rakefile +27 -2
- data/documentation/images/failed_order_processing.png +0 -0
- data/documentation/images/payment_workflow.png +0 -0
- data/documentation/interrupts.md +161 -0
- data/gui/.gitignore +24 -0
- data/gui/README.md +73 -0
- data/gui/eslint.config.js +23 -0
- data/gui/index.html +13 -0
- data/gui/package-lock.json +5925 -0
- data/gui/package.json +46 -0
- data/gui/postcss.config.js +6 -0
- data/gui/public/vite.svg +1 -0
- data/gui/src/App.css +42 -0
- data/gui/src/App.tsx +51 -0
- data/gui/src/assets/react.svg +1 -0
- data/gui/src/components/DagVisualizer.tsx +424 -0
- data/gui/src/components/Dashboard.tsx +163 -0
- data/gui/src/components/ErrorBoundary.tsx +47 -0
- data/gui/src/components/ReactorDetail.tsx +135 -0
- data/gui/src/components/StepInspector.tsx +492 -0
- data/gui/src/components/__tests__/DagVisualizer.test.tsx +140 -0
- data/gui/src/components/__tests__/ReactorDetail.test.tsx +111 -0
- data/gui/src/components/__tests__/StepInspector.test.tsx +408 -0
- data/gui/src/globals.d.ts +7 -0
- data/gui/src/index.css +14 -0
- data/gui/src/lib/utils.ts +13 -0
- data/gui/src/main.tsx +14 -0
- data/gui/src/test/setup.ts +11 -0
- data/gui/tailwind.config.js +11 -0
- data/gui/tsconfig.app.json +28 -0
- data/gui/tsconfig.json +7 -0
- data/gui/tsconfig.node.json +26 -0
- data/gui/vite.config.ts +8 -0
- data/gui/vitest.config.ts +13 -0
- data/lib/ruby_reactor/async_router.rb +6 -2
- data/lib/ruby_reactor/context.rb +35 -9
- data/lib/ruby_reactor/dependency_graph.rb +2 -0
- data/lib/ruby_reactor/dsl/compose_builder.rb +8 -0
- data/lib/ruby_reactor/dsl/interrupt_builder.rb +48 -0
- data/lib/ruby_reactor/dsl/interrupt_step_config.rb +21 -0
- data/lib/ruby_reactor/dsl/map_builder.rb +8 -0
- data/lib/ruby_reactor/dsl/reactor.rb +12 -0
- data/lib/ruby_reactor/dsl/step_builder.rb +4 -0
- data/lib/ruby_reactor/executor/compensation_manager.rb +60 -27
- data/lib/ruby_reactor/executor/graph_manager.rb +2 -0
- data/lib/ruby_reactor/executor/result_handler.rb +117 -39
- data/lib/ruby_reactor/executor/retry_manager.rb +1 -0
- data/lib/ruby_reactor/executor/step_executor.rb +38 -4
- data/lib/ruby_reactor/executor.rb +86 -13
- data/lib/ruby_reactor/interrupt_result.rb +20 -0
- data/lib/ruby_reactor/map/collector.rb +0 -2
- data/lib/ruby_reactor/map/element_executor.rb +3 -0
- data/lib/ruby_reactor/map/execution.rb +28 -1
- data/lib/ruby_reactor/map/helpers.rb +44 -6
- data/lib/ruby_reactor/reactor.rb +187 -1
- data/lib/ruby_reactor/registry.rb +25 -0
- data/lib/ruby_reactor/sidekiq_workers/worker.rb +1 -1
- data/lib/ruby_reactor/step/compose_step.rb +22 -6
- data/lib/ruby_reactor/step/map_step.rb +30 -3
- data/lib/ruby_reactor/storage/adapter.rb +32 -0
- data/lib/ruby_reactor/storage/redis_adapter.rb +154 -11
- data/lib/ruby_reactor/utils/code_extractor.rb +31 -0
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor/web/api.rb +206 -0
- data/lib/ruby_reactor/web/application.rb +53 -0
- data/lib/ruby_reactor/web/config.ru +5 -0
- data/lib/ruby_reactor/web/public/assets/index-VdeLgH9k.js +19 -0
- data/lib/ruby_reactor/web/public/assets/index-_z-6BvuM.css +1 -0
- data/lib/ruby_reactor/web/public/index.html +14 -0
- data/lib/ruby_reactor/web/public/vite.svg +1 -0
- data/lib/ruby_reactor.rb +94 -28
- data/llms-full.txt +66 -0
- data/llms.txt +7 -0
- metadata +63 -2
|
@@ -36,6 +36,9 @@ module RubyReactor
|
|
|
36
36
|
# If a step returns Failure, we need to stop execution and return it
|
|
37
37
|
return result if result.is_a?(RubyReactor::Failure)
|
|
38
38
|
|
|
39
|
+
# If a step returns InterruptResult, we need to stop execution and return it
|
|
40
|
+
return result if result.is_a?(RubyReactor::InterruptResult)
|
|
41
|
+
|
|
39
42
|
# If result is nil, it means async was executed inline (test mode), continue
|
|
40
43
|
next if result.nil?
|
|
41
44
|
end
|
|
@@ -49,7 +52,13 @@ module RubyReactor
|
|
|
49
52
|
# If we're already in inline async execution mode (inside Worker),
|
|
50
53
|
# treat async steps as sync to avoid infinite recursion
|
|
51
54
|
|
|
52
|
-
if
|
|
55
|
+
if @dependency_graph.completed.include?(step_config.name)
|
|
56
|
+
return RubyReactor.Success(@context.get_result(step_config.name))
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
if step_config.interrupt?
|
|
60
|
+
handle_interrupt_step(step_config)
|
|
61
|
+
elsif step_config.async? && !@context.inline_async_execution
|
|
53
62
|
handle_async_step(step_config)
|
|
54
63
|
else
|
|
55
64
|
execute_step_with_retry(step_config)
|
|
@@ -77,8 +86,10 @@ module RubyReactor
|
|
|
77
86
|
# Update retry context
|
|
78
87
|
@context.retry_context = other_executor.context.retry_context
|
|
79
88
|
|
|
80
|
-
#
|
|
81
|
-
|
|
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
|
|
82
93
|
|
|
83
94
|
# Update our dependency graph to reflect completed steps
|
|
84
95
|
other_executor.context.intermediate_results.each_key do |step_name|
|
|
@@ -91,7 +102,8 @@ module RubyReactor
|
|
|
91
102
|
# Merge any undo stack items
|
|
92
103
|
other_executor.undo_stack.each do |item|
|
|
93
104
|
# Avoid duplicates by checking if this step is already in the undo stack
|
|
94
|
-
|
|
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 }
|
|
95
107
|
@compensation_manager.add_to_undo_stack(item)
|
|
96
108
|
end
|
|
97
109
|
end
|
|
@@ -248,6 +260,28 @@ module RubyReactor
|
|
|
248
260
|
result.result
|
|
249
261
|
end
|
|
250
262
|
|
|
263
|
+
def handle_interrupt_step(step_config)
|
|
264
|
+
# Check if we have a result for this step (resuming)
|
|
265
|
+
if @context.intermediate_results.key?(step_config.name)
|
|
266
|
+
# We are resuming
|
|
267
|
+
result = @context.get_result(step_config.name)
|
|
268
|
+
return RubyReactor.Success(result)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# We are pausing
|
|
272
|
+
correlation_id = nil
|
|
273
|
+
correlation_id = step_config.correlation_id_block.call(@context) if step_config.correlation_id_block
|
|
274
|
+
|
|
275
|
+
# Store current step as the one we are paused at
|
|
276
|
+
@context.current_step = step_config.name
|
|
277
|
+
|
|
278
|
+
RubyReactor::InterruptResult.new(
|
|
279
|
+
execution_id: @context.context_id,
|
|
280
|
+
correlation_id: correlation_id,
|
|
281
|
+
intermediate_results: @context.intermediate_results
|
|
282
|
+
)
|
|
283
|
+
end
|
|
284
|
+
|
|
251
285
|
def configuration
|
|
252
286
|
RubyReactor::Configuration.instance
|
|
253
287
|
end
|
|
@@ -40,24 +40,49 @@ module RubyReactor
|
|
|
40
40
|
input_validator = InputValidator.new(@reactor_class, @context)
|
|
41
41
|
input_validator.validate!
|
|
42
42
|
|
|
43
|
+
save_context
|
|
44
|
+
|
|
43
45
|
graph_manager = GraphManager.new(@reactor_class, @dependency_graph, @context)
|
|
44
46
|
graph_manager.build_and_validate!
|
|
47
|
+
graph_manager.mark_completed_steps_from_context
|
|
45
48
|
|
|
46
49
|
@result = @step_executor.execute_all_steps
|
|
50
|
+
update_context_status(@result)
|
|
51
|
+
handle_interrupt(@result) if @result.is_a?(RubyReactor::InterruptResult)
|
|
52
|
+
@result
|
|
47
53
|
rescue StandardError => e
|
|
48
54
|
@result = @result_handler.handle_execution_error(e)
|
|
55
|
+
update_context_status(@result)
|
|
56
|
+
@result
|
|
57
|
+
ensure
|
|
58
|
+
save_context
|
|
49
59
|
end
|
|
50
60
|
|
|
51
61
|
def resume_execution
|
|
52
62
|
prepare_for_resume
|
|
63
|
+
save_context
|
|
53
64
|
|
|
54
|
-
if @context.current_step
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
65
|
+
@result = if @context.current_step
|
|
66
|
+
execute_current_step_and_continue
|
|
67
|
+
else
|
|
68
|
+
execute_remaining_steps
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
update_context_status(@result)
|
|
72
|
+
|
|
73
|
+
handle_interrupt(@result) if @result.is_a?(RubyReactor::InterruptResult)
|
|
74
|
+
|
|
75
|
+
@result
|
|
59
76
|
rescue StandardError => e
|
|
60
77
|
handle_resume_error(e)
|
|
78
|
+
update_context_status(@result)
|
|
79
|
+
@result
|
|
80
|
+
ensure
|
|
81
|
+
save_context
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def undo_all
|
|
85
|
+
@compensation_manager.rollback_completed_steps
|
|
61
86
|
end
|
|
62
87
|
|
|
63
88
|
def undo_stack
|
|
@@ -72,8 +97,45 @@ module RubyReactor
|
|
|
72
97
|
@context.execution_trace
|
|
73
98
|
end
|
|
74
99
|
|
|
100
|
+
def save_context
|
|
101
|
+
storage = RubyReactor::Configuration.instance.storage_adapter
|
|
102
|
+
reactor_class_name = @reactor_class.name || "AnonymousReactor-#{@reactor_class.object_id}"
|
|
103
|
+
|
|
104
|
+
# Serialize context
|
|
105
|
+
serialized_context = ContextSerializer.serialize(@context)
|
|
106
|
+
storage.store_context(@context.context_id, serialized_context, reactor_class_name)
|
|
107
|
+
end
|
|
108
|
+
|
|
75
109
|
private
|
|
76
110
|
|
|
111
|
+
def update_context_status(result)
|
|
112
|
+
return unless result
|
|
113
|
+
|
|
114
|
+
case result
|
|
115
|
+
when RubyReactor::AsyncResult
|
|
116
|
+
@context.status = :running
|
|
117
|
+
when RubyReactor::Success
|
|
118
|
+
@context.status = :completed
|
|
119
|
+
when RubyReactor::Failure
|
|
120
|
+
@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
|
+
}
|
|
134
|
+
when RubyReactor::InterruptResult
|
|
135
|
+
@context.status = :paused
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
77
139
|
def prepare_for_resume
|
|
78
140
|
# Build dependency graph and mark completed steps
|
|
79
141
|
graph_manager = GraphManager.new(@reactor_class, @dependency_graph, @context)
|
|
@@ -85,6 +147,9 @@ module RubyReactor
|
|
|
85
147
|
step_config = @reactor_class.steps[@context.current_step]
|
|
86
148
|
return RubyReactor::Failure("Step '#{@context.current_step}' not found in reactor") unless step_config
|
|
87
149
|
|
|
150
|
+
# If current step is already in intermediate_results, skip directly to execute_all_steps
|
|
151
|
+
return @step_executor.execute_all_steps if @context.intermediate_results.key?(@context.current_step.to_sym)
|
|
152
|
+
|
|
88
153
|
# Use execute_step (not execute_step_with_retry) so that async steps can be handled properly in inline mode
|
|
89
154
|
result = @step_executor.execute_step(step_config)
|
|
90
155
|
|
|
@@ -93,7 +158,7 @@ module RubyReactor
|
|
|
93
158
|
@result = @step_executor.execute_all_steps
|
|
94
159
|
else
|
|
95
160
|
case result
|
|
96
|
-
when RetryQueuedResult, RubyReactor::Failure, RubyReactor::AsyncResult
|
|
161
|
+
when RetryQueuedResult, RubyReactor::Failure, RubyReactor::AsyncResult, RubyReactor::InterruptResult
|
|
97
162
|
# Step was requeued, failed, or handed off to async - return the result
|
|
98
163
|
@result = result
|
|
99
164
|
when RubyReactor::Success
|
|
@@ -110,14 +175,22 @@ module RubyReactor
|
|
|
110
175
|
end
|
|
111
176
|
|
|
112
177
|
def handle_resume_error(error)
|
|
113
|
-
|
|
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
|
|
178
|
+
@result = @result_handler.handle_execution_error(error)
|
|
120
179
|
@result
|
|
121
180
|
end
|
|
181
|
+
|
|
182
|
+
def handle_interrupt(interrupt_result)
|
|
183
|
+
save_context
|
|
184
|
+
|
|
185
|
+
# Store correlation ID mapping if present
|
|
186
|
+
return unless interrupt_result.correlation_id
|
|
187
|
+
|
|
188
|
+
storage = RubyReactor::Configuration.instance.storage_adapter
|
|
189
|
+
storage.store_correlation_id(
|
|
190
|
+
interrupt_result.correlation_id,
|
|
191
|
+
@context.context_id,
|
|
192
|
+
@reactor_class.name
|
|
193
|
+
)
|
|
194
|
+
end
|
|
122
195
|
end
|
|
123
196
|
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
class InterruptResult
|
|
5
|
+
attr_reader :execution_id, :correlation_id, :status, :timeout_at, :intermediate_results, :error
|
|
6
|
+
|
|
7
|
+
def initialize(execution_id:, correlation_id: nil, timeout_at: nil, intermediate_results: {}, error: nil)
|
|
8
|
+
@execution_id = execution_id
|
|
9
|
+
@correlation_id = correlation_id
|
|
10
|
+
@status = :paused
|
|
11
|
+
@timeout_at = timeout_at
|
|
12
|
+
@intermediate_results = intermediate_results
|
|
13
|
+
@error = error
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def paused?
|
|
17
|
+
true
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -5,7 +5,6 @@ module RubyReactor
|
|
|
5
5
|
class Collector
|
|
6
6
|
extend Helpers
|
|
7
7
|
|
|
8
|
-
# rubocop:disable Metrics/MethodLength
|
|
9
8
|
def self.perform(arguments)
|
|
10
9
|
arguments = arguments.transform_keys(&:to_sym)
|
|
11
10
|
parent_context_id = arguments[:parent_context_id]
|
|
@@ -59,7 +58,6 @@ module RubyReactor
|
|
|
59
58
|
# Resume execution
|
|
60
59
|
resume_parent_execution(parent_context, step_name, final_result, storage)
|
|
61
60
|
end
|
|
62
|
-
# rubocop:enable Metrics/MethodLength
|
|
63
61
|
end
|
|
64
62
|
end
|
|
65
63
|
end
|
|
@@ -34,9 +34,12 @@ module RubyReactor
|
|
|
34
34
|
|
|
35
35
|
# Create context
|
|
36
36
|
context = Context.new(inputs, reactor_class)
|
|
37
|
+
context.parent_context_id = parent_context_id
|
|
37
38
|
context.map_metadata = arguments
|
|
38
39
|
end
|
|
40
|
+
|
|
39
41
|
storage = RubyReactor.configuration.storage_adapter
|
|
42
|
+
storage.store_map_element_context_id(map_id, context.context_id, parent_reactor_class_name)
|
|
40
43
|
|
|
41
44
|
# Execute
|
|
42
45
|
executor = Executor.new(reactor_class, {}, context)
|
|
@@ -32,7 +32,27 @@ module RubyReactor
|
|
|
32
32
|
def self.execute_all_elements(source:, mappings:, reactor_class:, parent_context:, storage_options:)
|
|
33
33
|
source.map.with_index do |element, index|
|
|
34
34
|
element_inputs = build_element_inputs(mappings, parent_context, element)
|
|
35
|
-
|
|
35
|
+
|
|
36
|
+
# Manually create and link context to ensure parent_context_id is set
|
|
37
|
+
child_context = RubyReactor::Context.new(element_inputs, reactor_class)
|
|
38
|
+
link_contexts(child_context, parent_context)
|
|
39
|
+
|
|
40
|
+
# Ensure we store the element context linkage
|
|
41
|
+
storage_options[:storage].store_map_element_context_id(
|
|
42
|
+
storage_options[:map_id], child_context.context_id, storage_options[:parent_reactor_class_name]
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# Set map metadata for failure handling
|
|
46
|
+
metadata = {
|
|
47
|
+
map_id: storage_options[:map_id],
|
|
48
|
+
parent_reactor_class_name: storage_options[:parent_reactor_class_name],
|
|
49
|
+
index: index
|
|
50
|
+
}
|
|
51
|
+
child_context.map_metadata = metadata
|
|
52
|
+
|
|
53
|
+
executor = RubyReactor::Executor.new(reactor_class, {}, child_context)
|
|
54
|
+
executor.execute
|
|
55
|
+
result = executor.result
|
|
36
56
|
|
|
37
57
|
store_result(result, index, storage_options)
|
|
38
58
|
|
|
@@ -40,6 +60,13 @@ module RubyReactor
|
|
|
40
60
|
end
|
|
41
61
|
end
|
|
42
62
|
|
|
63
|
+
def self.link_contexts(child_context, parent_context)
|
|
64
|
+
child_context.parent_context = parent_context
|
|
65
|
+
child_context.root_context = parent_context.root_context || parent_context
|
|
66
|
+
child_context.test_mode = parent_context.test_mode
|
|
67
|
+
child_context.inline_async_execution = parent_context.inline_async_execution
|
|
68
|
+
end
|
|
69
|
+
|
|
43
70
|
def self.store_result(result, index, options)
|
|
44
71
|
value = result.success? ? result.value : { _error: result.error }
|
|
45
72
|
options[:storage].store_map_result(
|
|
@@ -7,7 +7,11 @@ module RubyReactor
|
|
|
7
7
|
# Resolves the reactor class from reactor_class_info
|
|
8
8
|
def resolve_reactor_class(info)
|
|
9
9
|
if info["type"] == "class"
|
|
10
|
-
|
|
10
|
+
begin
|
|
11
|
+
Object.const_get(info["name"])
|
|
12
|
+
rescue NameError
|
|
13
|
+
RubyReactor::Registry.find(info["name"])
|
|
14
|
+
end
|
|
11
15
|
elsif info["type"] == "inline"
|
|
12
16
|
parent_class = Object.const_get(info["parent"])
|
|
13
17
|
step_config = parent_class.steps[info["step"].to_sym]
|
|
@@ -49,12 +53,46 @@ module RubyReactor
|
|
|
49
53
|
|
|
50
54
|
# Resumes parent reactor execution after map completion
|
|
51
55
|
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
56
|
executor = RubyReactor::Executor.new(parent_context.reactor_class, {}, parent_context)
|
|
57
|
-
|
|
57
|
+
|
|
58
|
+
if final_result.failure?
|
|
59
|
+
step_name_sym = step_name.to_sym
|
|
60
|
+
parent_context.current_step = step_name_sym
|
|
61
|
+
|
|
62
|
+
error = RubyReactor::Error::StepFailureError.new(
|
|
63
|
+
final_result.error,
|
|
64
|
+
step: step_name_sym,
|
|
65
|
+
context: parent_context,
|
|
66
|
+
original_error: final_result.error.is_a?(Exception) ? final_result.error : nil
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# Pass backtrace if available
|
|
70
|
+
if final_result.respond_to?(:backtrace) && final_result.backtrace
|
|
71
|
+
error.set_backtrace(final_result.backtrace)
|
|
72
|
+
elsif final_result.error.respond_to?(:backtrace)
|
|
73
|
+
error.set_backtrace(final_result.error.backtrace)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
failure_response = executor.result_handler.handle_execution_error(error)
|
|
77
|
+
# Manually update context status since we're not running executor loop
|
|
78
|
+
executor.send(:update_context_status, failure_response)
|
|
79
|
+
else
|
|
80
|
+
parent_context.set_result(step_name.to_sym, final_result.value)
|
|
81
|
+
|
|
82
|
+
# Manually update execution trace to reflect completion
|
|
83
|
+
# This is necessary because resume_execution continues from the NEXT step
|
|
84
|
+
# and the async step (which returned AsyncResult) needs to be marked as done with actual value
|
|
85
|
+
parent_context.execution_trace << {
|
|
86
|
+
type: :result,
|
|
87
|
+
step: step_name_sym,
|
|
88
|
+
timestamp: Time.now,
|
|
89
|
+
value: final_result.value,
|
|
90
|
+
status: :success
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
parent_context.current_step = nil
|
|
94
|
+
executor.resume_execution
|
|
95
|
+
end
|
|
58
96
|
|
|
59
97
|
storage.store_context(
|
|
60
98
|
parent_context.context_id,
|
data/lib/ruby_reactor/reactor.rb
CHANGED
|
@@ -6,6 +6,61 @@ module RubyReactor
|
|
|
6
6
|
|
|
7
7
|
attr_reader :context, :result, :undo_trace, :execution_trace
|
|
8
8
|
|
|
9
|
+
def self.find(id)
|
|
10
|
+
reactor_class_name = name
|
|
11
|
+
serialized_context = configuration.storage_adapter.retrieve_context(id, reactor_class_name)
|
|
12
|
+
raise Error::ValidationError, "Context '#{id}' not found" unless serialized_context
|
|
13
|
+
|
|
14
|
+
context = Context.deserialize_from_retry(serialized_context)
|
|
15
|
+
new(context)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.find_by_correlation_id(correlation_id)
|
|
19
|
+
reactor_class_name = name
|
|
20
|
+
context_id = configuration.storage_adapter.retrieve_context_id_by_correlation_id(
|
|
21
|
+
correlation_id,
|
|
22
|
+
reactor_class_name
|
|
23
|
+
)
|
|
24
|
+
raise Error::ValidationError, "Correlation ID '#{correlation_id}' not found" unless context_id
|
|
25
|
+
|
|
26
|
+
find(context_id)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.continue(id:, payload:, step_name:, idempotency_key: nil)
|
|
30
|
+
reactor = find(id)
|
|
31
|
+
result = reactor.continue(payload: payload, step_name: step_name, idempotency_key: idempotency_key)
|
|
32
|
+
|
|
33
|
+
if result.is_a?(RubyReactor::Failure) && result.respond_to?(:invalid_payload?) && result.invalid_payload?
|
|
34
|
+
# Raise exception to match expected behavior (strict mode for class method)
|
|
35
|
+
# We do NOT cancel the reactor, allowing the user to retry with valid payload
|
|
36
|
+
raise Error::InputValidationError, result.error
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
result
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.continue_by_correlation_id(correlation_id:, payload:, step_name:, idempotency_key: nil)
|
|
43
|
+
reactor = find_by_correlation_id(correlation_id)
|
|
44
|
+
# We delegate to the class-level continue method to ensure auto-compensation logic applies
|
|
45
|
+
# by using the context ID found by find_by_correlation_id
|
|
46
|
+
continue(id: reactor.context.context_id, payload: payload, step_name: step_name, idempotency_key: idempotency_key)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.cancel(id:, reason:)
|
|
50
|
+
reactor = find(id)
|
|
51
|
+
reactor.cancel(reason)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.undo(id)
|
|
55
|
+
reactor = find(id)
|
|
56
|
+
reactor.undo
|
|
57
|
+
cancel(id: id, reason: "Undo triggered")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def self.configuration
|
|
61
|
+
RubyReactor::Configuration.instance
|
|
62
|
+
end
|
|
63
|
+
|
|
9
64
|
def initialize(context = {})
|
|
10
65
|
@context = context
|
|
11
66
|
@result = :unexecuted
|
|
@@ -21,11 +76,13 @@ module RubyReactor
|
|
|
21
76
|
configuration.async_router.perform_async(serialized_context)
|
|
22
77
|
else
|
|
23
78
|
# For sync reactors (potentially with async steps), execute normally
|
|
24
|
-
|
|
79
|
+
context = @context.is_a?(Context) ? @context : nil
|
|
80
|
+
executor = Executor.new(self.class, inputs, context)
|
|
25
81
|
@result = executor.execute
|
|
26
82
|
|
|
27
83
|
@context = executor.context
|
|
28
84
|
|
|
85
|
+
# Merge traces
|
|
29
86
|
@undo_trace = executor.undo_trace
|
|
30
87
|
@execution_trace = executor.execution_trace
|
|
31
88
|
|
|
@@ -36,6 +93,55 @@ module RubyReactor
|
|
|
36
93
|
end
|
|
37
94
|
end
|
|
38
95
|
|
|
96
|
+
def continue(payload:, step_name:, idempotency_key: nil)
|
|
97
|
+
_ = idempotency_key
|
|
98
|
+
|
|
99
|
+
unless @context.current_step
|
|
100
|
+
raise Error::ValidationError, "Cannot resume: context does not have a current step (was it interrupted?)"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
if @context.cancelled
|
|
104
|
+
raise Error::ValidationError,
|
|
105
|
+
"Cannot resume: reactor has been cancelled (Reason: #{@context.cancellation_reason})"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
validate_continue_step!(step_name)
|
|
109
|
+
|
|
110
|
+
if (failure = validate_continue_payload(payload, step_name))
|
|
111
|
+
return failure
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
target_step = step_name
|
|
115
|
+
@context.set_result(target_step, payload)
|
|
116
|
+
|
|
117
|
+
# Resume execution
|
|
118
|
+
executor = Executor.new(self.class, {}, @context)
|
|
119
|
+
@result = executor.resume_execution
|
|
120
|
+
|
|
121
|
+
@context = executor.context
|
|
122
|
+
@undo_trace = executor.undo_trace
|
|
123
|
+
@execution_trace = executor.execution_trace
|
|
124
|
+
|
|
125
|
+
@result
|
|
126
|
+
rescue Error::InputValidationError => e
|
|
127
|
+
# This might catch other validations, but here we specifically want payload validation.
|
|
128
|
+
# The block above handles payload validation explicitly.
|
|
129
|
+
RubyReactor::Failure(e.message, invalid_payload: true)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def undo
|
|
133
|
+
executor = Executor.new(self.class, {}, @context)
|
|
134
|
+
executor.undo_all
|
|
135
|
+
executor.save_context
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def cancel(reason)
|
|
139
|
+
@context.cancelled = true
|
|
140
|
+
@context.cancellation_reason = reason
|
|
141
|
+
@context.status = "cancelled"
|
|
142
|
+
save_context
|
|
143
|
+
end
|
|
144
|
+
|
|
39
145
|
def validate!
|
|
40
146
|
# Validate reactor configuration
|
|
41
147
|
validate_steps!
|
|
@@ -71,5 +177,85 @@ module RubyReactor
|
|
|
71
177
|
|
|
72
178
|
raise Error::DependencyError, "Dependency graph contains cycles"
|
|
73
179
|
end
|
|
180
|
+
|
|
181
|
+
def validate_continue_step!(step_name)
|
|
182
|
+
return if step_name.to_s == @context.current_step.to_s
|
|
183
|
+
|
|
184
|
+
# Build graph to check if step is ready
|
|
185
|
+
graph_manager = Executor::GraphManager.new(self.class, DependencyGraph.new, @context)
|
|
186
|
+
graph_manager.build_and_validate!
|
|
187
|
+
graph_manager.mark_completed_steps_from_context
|
|
188
|
+
ready_steps = graph_manager.dependency_graph.ready_steps.map(&:name).map(&:to_s)
|
|
189
|
+
|
|
190
|
+
return if ready_steps.include?(step_name.to_s)
|
|
191
|
+
|
|
192
|
+
raise Error::ValidationError,
|
|
193
|
+
"Cannot resume: expected step '#{@context.current_step}' " \
|
|
194
|
+
"or ready steps #{ready_steps} but got '#{step_name}'"
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def validate_continue_payload(payload, step_name)
|
|
198
|
+
step_config = self.class.steps[step_name.to_sym]
|
|
199
|
+
return unless step_config&.validation_schema
|
|
200
|
+
|
|
201
|
+
validation = step_config.validation_schema.call(payload)
|
|
202
|
+
|
|
203
|
+
return unless validation.failure?
|
|
204
|
+
|
|
205
|
+
# Track attempts
|
|
206
|
+
step_key = step_name.to_sym
|
|
207
|
+
@context.private_data[:interrupt_attempts] ||= {}
|
|
208
|
+
@context.private_data[:interrupt_attempts][step_key] ||= 0
|
|
209
|
+
@context.private_data[:interrupt_attempts][step_key] += 1
|
|
210
|
+
|
|
211
|
+
save_context # Persist the attempt count
|
|
212
|
+
|
|
213
|
+
current_attempts = @context.private_data[:interrupt_attempts][step_key]
|
|
214
|
+
max_attempts = step_config.max_attempts
|
|
215
|
+
|
|
216
|
+
if max_attempts != :infinity && current_attempts >= max_attempts
|
|
217
|
+
# Max attempts reached - Fail and Compensate
|
|
218
|
+
undo
|
|
219
|
+
|
|
220
|
+
# Instead of cancelling, we mark as failed so it shows up as failed in UI
|
|
221
|
+
@context.status = "failed"
|
|
222
|
+
@context.failure_reason = {
|
|
223
|
+
message: "Validation failed after #{max_attempts} attempts",
|
|
224
|
+
step_name: step_name,
|
|
225
|
+
errors: validation.errors.to_h,
|
|
226
|
+
payload: payload,
|
|
227
|
+
step_arguments: payload,
|
|
228
|
+
attempts: current_attempts,
|
|
229
|
+
validation_errors: validation.errors.to_h
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
save_context
|
|
233
|
+
|
|
234
|
+
return RubyReactor::Failure(
|
|
235
|
+
"Validation failed after #{max_attempts} attempts",
|
|
236
|
+
step_name: step_name,
|
|
237
|
+
step_arguments: payload,
|
|
238
|
+
validation_errors: validation.errors.to_h
|
|
239
|
+
)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
failure = RubyReactor::Failure(validation.errors.to_h)
|
|
243
|
+
# We need a way to mark this failure as a validation failure
|
|
244
|
+
# For now, we rely on the error object inside Failure or just return Failure
|
|
245
|
+
# The PRD requires `result.invalid_payload?` to be true.
|
|
246
|
+
# Since we don't have that method on Failure yet, we might need to enhance Failure
|
|
247
|
+
# OR wrap it. For now, let's assume Failure wraps the error and we can check it.
|
|
248
|
+
# We'll use a specific error type to identify it.
|
|
249
|
+
failure.instance_variable_set(:@type, :input_validation)
|
|
250
|
+
def failure.invalid_payload? = true
|
|
251
|
+
failure
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def save_context
|
|
255
|
+
storage = configuration.storage_adapter
|
|
256
|
+
reactor_class_name = self.class.name || "AnonymousReactor-#{self.class.object_id}"
|
|
257
|
+
serialized_context = ContextSerializer.serialize(@context)
|
|
258
|
+
storage.store_context(@context.context_id, serialized_context, reactor_class_name)
|
|
259
|
+
end
|
|
74
260
|
end
|
|
75
261
|
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "concurrent"
|
|
4
|
+
|
|
5
|
+
module RubyReactor
|
|
6
|
+
# Registry for dynamically created reactor classes (e.g. from inline map/compose)
|
|
7
|
+
# This avoids polluting the global constant namespace with runtime-generated constants.
|
|
8
|
+
class Registry
|
|
9
|
+
@reactors = Concurrent::Map.new
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
def register(name, klass)
|
|
13
|
+
@reactors[name] = klass
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def find(name)
|
|
17
|
+
@reactors[name]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def clear!
|
|
21
|
+
@reactors.clear
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -37,8 +37,8 @@ module RubyReactor
|
|
|
37
37
|
|
|
38
38
|
# Resume execution from the failed step
|
|
39
39
|
executor = Executor.new(context.reactor_class, {}, context)
|
|
40
|
-
executor.compensation_manager.undo_stack.concat(context.undo_stack)
|
|
41
40
|
executor.resume_execution
|
|
41
|
+
executor.save_context
|
|
42
42
|
|
|
43
43
|
# Return the executor (which now has the result stored in it)
|
|
44
44
|
executor
|
|
@@ -29,12 +29,28 @@ module RubyReactor
|
|
|
29
29
|
handle_execution_result(result)
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
-
def self.compensate(_reason,
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
32
|
+
def self.compensate(_reason, arguments, context)
|
|
33
|
+
step_name = context.current_step
|
|
34
|
+
composed_data = context.composed_contexts[step_name]
|
|
35
|
+
return RubyReactor.Success() unless composed_data && composed_data[:context]
|
|
36
|
+
|
|
37
|
+
child_context = composed_data[:context]
|
|
38
|
+
executor = RubyReactor::Executor.new(arguments[:composed_reactor_class], {}, child_context)
|
|
39
|
+
executor.undo_all
|
|
40
|
+
executor.save_context
|
|
41
|
+
|
|
42
|
+
RubyReactor.Success()
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.undo(_result, arguments, context)
|
|
46
|
+
step_name = context.current_step
|
|
47
|
+
composed_data = context.composed_contexts[step_name]
|
|
48
|
+
return RubyReactor.Success() unless composed_data && composed_data[:context]
|
|
49
|
+
|
|
50
|
+
child_context = composed_data[:context]
|
|
51
|
+
executor = RubyReactor::Executor.new(arguments[:composed_reactor_class], {}, child_context)
|
|
52
|
+
executor.undo_all
|
|
53
|
+
executor.save_context
|
|
38
54
|
|
|
39
55
|
RubyReactor.Success()
|
|
40
56
|
end
|