ruby_reactor 0.4.1 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.release-please-manifest.json +1 -1
- data/.rubocop.yml +1 -0
- data/CHANGELOG.md +14 -0
- data/README.md +153 -18
- data/lib/ruby_reactor/configuration.rb +13 -1
- data/lib/ruby_reactor/context.rb +2 -1
- data/lib/ruby_reactor/context_serializer.rb +1 -3
- data/lib/ruby_reactor/dsl/interrupt_builder.rb +18 -2
- data/lib/ruby_reactor/dsl/lockable.rb +19 -28
- data/lib/ruby_reactor/dsl/reactor.rb +44 -9
- data/lib/ruby_reactor/dsl/step_builder.rb +25 -39
- data/lib/ruby_reactor/dsl/validation_helpers.rb +34 -0
- data/lib/ruby_reactor/error/input_validation_error.rb +4 -0
- data/lib/ruby_reactor/executor/compensation_manager.rb +75 -47
- data/lib/ruby_reactor/executor/result_handler.rb +35 -8
- data/lib/ruby_reactor/executor/retry_manager.rb +15 -5
- data/lib/ruby_reactor/executor/step_executor.rb +46 -23
- data/lib/ruby_reactor/executor.rb +188 -49
- data/lib/ruby_reactor/map/collector.rb +4 -4
- data/lib/ruby_reactor/map/element_executor.rb +15 -1
- data/lib/ruby_reactor/map/helpers.rb +17 -4
- data/lib/ruby_reactor/middleware.rb +13 -0
- data/lib/ruby_reactor/middleware_runner.rb +29 -0
- data/lib/ruby_reactor/open_telemetry.rb +647 -0
- data/lib/ruby_reactor/rate_limit.rb +28 -0
- data/lib/ruby_reactor/rate_limit_registry.rb +51 -0
- data/lib/ruby_reactor/reactor.rb +1 -0
- data/lib/ruby_reactor/rspec/test_subject.rb +0 -1
- data/lib/ruby_reactor/sidekiq_adapter.rb +7 -21
- data/lib/ruby_reactor/sidekiq_workers/worker.rb +4 -0
- data/lib/ruby_reactor/step/map_step.rb +25 -33
- data/lib/ruby_reactor/validation/base.rb +4 -1
- data/lib/ruby_reactor/validation/input_validator.rb +4 -2
- data/lib/ruby_reactor/validation/schema_builder.rb +82 -0
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor/web/coordination_serializer.rb +12 -18
- data/teley/Dockerfile +60 -0
- metadata +6 -3
- data/lib/ruby_reactor/map/execution.rb +0 -101
- data/lib/ruby_reactor/sidekiq_workers/map_execution_worker.rb +0 -15
|
@@ -51,61 +51,89 @@ module RubyReactor
|
|
|
51
51
|
|
|
52
52
|
private
|
|
53
53
|
|
|
54
|
+
def middlewares
|
|
55
|
+
@context.middlewares || RubyReactor::MiddlewareRunner.new([])
|
|
56
|
+
end
|
|
57
|
+
|
|
54
58
|
def compensate_step(step_config, error, arguments)
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
logged_result = if compensate_result.respond_to?(:value)
|
|
65
|
-
compensate_result.value
|
|
66
|
-
elsif compensate_result.respond_to?(:error)
|
|
67
|
-
compensate_result.error
|
|
68
|
-
else
|
|
69
|
-
compensate_result
|
|
70
|
-
end
|
|
59
|
+
middlewares.on(:start_compensation, step_config.name, error, arguments, @context)
|
|
60
|
+
begin
|
|
61
|
+
compensate_result = if step_config.compensate_block
|
|
62
|
+
step_config.compensate_block.call(error, arguments, @context)
|
|
63
|
+
elsif step_config.has_impl?
|
|
64
|
+
step_config.impl.compensate(error, arguments, @context)
|
|
65
|
+
else
|
|
66
|
+
RubyReactor.Success() # Default compensation
|
|
67
|
+
end
|
|
71
68
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
69
|
+
# Ensure we have a value to log
|
|
70
|
+
logged_result = if compensate_result.respond_to?(:value)
|
|
71
|
+
compensate_result.value
|
|
72
|
+
elsif compensate_result.respond_to?(:error)
|
|
73
|
+
compensate_result.error
|
|
74
|
+
else
|
|
75
|
+
compensate_result
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
@context.execution_trace << {
|
|
79
|
+
type: :compensate,
|
|
80
|
+
step: step_config.name,
|
|
81
|
+
timestamp: Time.now,
|
|
82
|
+
result: logged_result,
|
|
83
|
+
arguments: arguments
|
|
84
|
+
}
|
|
85
|
+
@undo_trace << { type: :compensation, step: step_config.name, error: error, arguments: arguments }
|
|
86
|
+
|
|
87
|
+
if compensate_result.is_a?(RubyReactor::Failure)
|
|
88
|
+
middlewares.on(:failed_compensation, step_config.name, compensate_result, @context)
|
|
89
|
+
else
|
|
90
|
+
middlewares.on(:complete_compensation, step_config.name, compensate_result, @context)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
compensate_result
|
|
94
|
+
rescue StandardError => e
|
|
95
|
+
middlewares.on(:failed_compensation, step_config.name, e, @context)
|
|
96
|
+
raise e
|
|
97
|
+
end
|
|
81
98
|
end
|
|
82
99
|
|
|
83
100
|
def undo_step(step_config, result, arguments)
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
# Ensure we have a value to log (if it's a Success/Failure object, get the value or error)
|
|
93
|
-
logged_result = if undo_result.respond_to?(:value)
|
|
94
|
-
undo_result.value
|
|
95
|
-
elsif undo_result.respond_to?(:error)
|
|
96
|
-
undo_result.error
|
|
101
|
+
middlewares.on(:start_undo, step_config.name, result, arguments, @context)
|
|
102
|
+
begin
|
|
103
|
+
undo_result = if step_config.undo_block
|
|
104
|
+
step_config.undo_block.call(result.value, arguments, @context)
|
|
105
|
+
elsif step_config.has_impl?
|
|
106
|
+
step_config.impl.undo(result.value, arguments, @context)
|
|
97
107
|
else
|
|
98
|
-
|
|
108
|
+
RubyReactor.Success()
|
|
99
109
|
end
|
|
100
110
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
111
|
+
# Ensure we have a value to log (if it's a Success/Failure object, get the value or error)
|
|
112
|
+
logged_result = if undo_result.respond_to?(:value)
|
|
113
|
+
undo_result.value
|
|
114
|
+
elsif undo_result.respond_to?(:error)
|
|
115
|
+
undo_result.error
|
|
116
|
+
else
|
|
117
|
+
undo_result
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
@context.execution_trace << { type: :undo, step: step_config.name, timestamp: Time.now, result: logged_result,
|
|
121
|
+
arguments: arguments }
|
|
122
|
+
|
|
123
|
+
if undo_result.is_a?(RubyReactor::Failure)
|
|
124
|
+
middlewares.on(:failed_undo, step_config.name, undo_result, @context)
|
|
125
|
+
else
|
|
126
|
+
middlewares.on(:complete_undo, step_config.name, undo_result, @context)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
undo_result
|
|
130
|
+
rescue StandardError => e
|
|
131
|
+
middlewares.on(:failed_undo, step_config.name, e, @context)
|
|
132
|
+
# Log undo failure but don't halt the rollback process
|
|
133
|
+
@context.execution_trace << { type: :undo_failure, step: step_config.name, timestamp: Time.now,
|
|
134
|
+
error: e.message }
|
|
135
|
+
RubyReactor.Failure(e)
|
|
136
|
+
end
|
|
109
137
|
end
|
|
110
138
|
end
|
|
111
139
|
end
|
|
@@ -33,8 +33,11 @@ module RubyReactor
|
|
|
33
33
|
when Error::StepFailureError
|
|
34
34
|
handle_step_failure_error(error)
|
|
35
35
|
when Error::InputValidationError
|
|
36
|
-
#
|
|
37
|
-
|
|
36
|
+
# Unified validation failure shape (inputs, step args, step output).
|
|
37
|
+
# Roll back any completed steps so saga semantics hold for mid-reactor
|
|
38
|
+
# validation failures (a no-op for input validation at reactor start).
|
|
39
|
+
@compensation_manager.rollback_completed_steps
|
|
40
|
+
build_validation_failure(error)
|
|
38
41
|
when Error::Base
|
|
39
42
|
# Other errors need rollback
|
|
40
43
|
@compensation_manager.rollback_completed_steps
|
|
@@ -56,6 +59,25 @@ module RubyReactor
|
|
|
56
59
|
|
|
57
60
|
private
|
|
58
61
|
|
|
62
|
+
# Failure for a validation error (reactor inputs, step arguments, or
|
|
63
|
+
# step output), carrying both the structured field errors and the step/
|
|
64
|
+
# reactor attribution stamped at the raise site (nil step_name for
|
|
65
|
+
# reactor-level input failures).
|
|
66
|
+
def build_validation_failure(error)
|
|
67
|
+
redact_inputs = []
|
|
68
|
+
redact_inputs = @context.reactor_class.inputs.select { |_, c| c[:redact] }.keys if @context.reactor_class
|
|
69
|
+
|
|
70
|
+
RubyReactor.Failure(
|
|
71
|
+
error,
|
|
72
|
+
validation_errors: error.field_errors,
|
|
73
|
+
step_name: error.step_name,
|
|
74
|
+
step_arguments: error.step_arguments || {},
|
|
75
|
+
inputs: @context.inputs,
|
|
76
|
+
redact_inputs: redact_inputs,
|
|
77
|
+
reactor_name: @context.reactor_class&.name
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
|
|
59
81
|
# A step returned `RubyReactor.Skipped(...)`. Halt cleanly: record the
|
|
60
82
|
# event in the trace, do NOT push to the undo stack (so existing
|
|
61
83
|
# completed steps stay as-is — no compensation), and stamp the step
|
|
@@ -180,12 +202,17 @@ module RubyReactor
|
|
|
180
202
|
output_validation_result = step_config.output_validator.call(value)
|
|
181
203
|
return if output_validation_result.success?
|
|
182
204
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
205
|
+
error = output_validation_result.error
|
|
206
|
+
error.step_name = step_config.name
|
|
207
|
+
error.step_arguments = resolved_arguments
|
|
208
|
+
|
|
209
|
+
# The step DID run — its side effect exists even though its output is
|
|
210
|
+
# invalid. Treat it like a step failure: run the step's own
|
|
211
|
+
# compensation and roll back prior steps, so the side effect is not
|
|
212
|
+
# orphaned. Then surface the structured validation error (the later
|
|
213
|
+
# rollback in handle_execution_error is a no-op — stack already clear).
|
|
214
|
+
@compensation_manager.handle_step_failure(step_config, error, resolved_arguments)
|
|
215
|
+
raise error
|
|
189
216
|
end
|
|
190
217
|
|
|
191
218
|
def extract_location(backtrace)
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
module RubyReactor
|
|
4
4
|
class Executor
|
|
5
5
|
class RetryManager
|
|
6
|
-
def initialize(context)
|
|
6
|
+
def initialize(context, middlewares = nil)
|
|
7
7
|
@context = context
|
|
8
|
+
@middlewares = middlewares || context.middlewares || Executor.middlewares_for(context.reactor_class)
|
|
8
9
|
end
|
|
9
10
|
|
|
10
11
|
def execute_with_retry(step_config, reactor_class)
|
|
@@ -47,11 +48,10 @@ module RubyReactor
|
|
|
47
48
|
@context.root_context || @context
|
|
48
49
|
end
|
|
49
50
|
|
|
50
|
-
puts "SERIALIZING CONTEXT: #{context_to_serialize.reactor_class.name}"
|
|
51
|
-
puts "INPUTS KEYS: #{context_to_serialize.inputs.keys}" if context_to_serialize.respond_to?(:inputs)
|
|
52
|
-
|
|
53
51
|
reactor_class_name = context_to_serialize.reactor_class.name
|
|
54
52
|
|
|
53
|
+
@middlewares.on(:before_async_enqueue, context_to_serialize)
|
|
54
|
+
|
|
55
55
|
serialized_context = ContextSerializer.serialize(context_to_serialize)
|
|
56
56
|
|
|
57
57
|
if @context.map_metadata
|
|
@@ -68,7 +68,8 @@ module RubyReactor
|
|
|
68
68
|
parent_reactor_class_name: map_args[:parent_reactor_class_name],
|
|
69
69
|
step_name: map_args[:step_name],
|
|
70
70
|
batch_size: map_args[:batch_size],
|
|
71
|
-
serialized_context: serialized_context
|
|
71
|
+
serialized_context: serialized_context,
|
|
72
|
+
fail_fast: map_args[:fail_fast]
|
|
72
73
|
)
|
|
73
74
|
else
|
|
74
75
|
configuration.async_router.perform_in(delay, serialized_context, reactor_class_name)
|
|
@@ -111,6 +112,15 @@ module RubyReactor
|
|
|
111
112
|
end
|
|
112
113
|
|
|
113
114
|
def handle_retryable_failure(step_config, reactor_class, result)
|
|
115
|
+
attempt_number = @context.retry_context.attempts_for_step(step_config.name)
|
|
116
|
+
@middlewares.on(
|
|
117
|
+
:retry_attempt,
|
|
118
|
+
step_config.name,
|
|
119
|
+
attempt_number,
|
|
120
|
+
result.error,
|
|
121
|
+
@context
|
|
122
|
+
)
|
|
123
|
+
|
|
114
124
|
# Check if we should requeue (async retry)
|
|
115
125
|
is_async = reactor_class.async? || step_config.async? ||
|
|
116
126
|
@context.root_context&.reactor_class&.async? ||
|
|
@@ -10,6 +10,7 @@ module RubyReactor
|
|
|
10
10
|
@retry_manager = managers[:retry_manager]
|
|
11
11
|
@result_handler = managers[:result_handler]
|
|
12
12
|
@compensation_manager = managers[:compensation_manager]
|
|
13
|
+
@middlewares = managers[:middlewares] || context.middlewares || Executor.middlewares_for(reactor_class)
|
|
13
14
|
end
|
|
14
15
|
|
|
15
16
|
def execute_all_steps
|
|
@@ -61,12 +62,28 @@ module RubyReactor
|
|
|
61
62
|
return RubyReactor.Success(@context.get_result(step_config.name))
|
|
62
63
|
end
|
|
63
64
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
65
|
+
resolved_arguments = resolve_arguments(step_config)
|
|
66
|
+
|
|
67
|
+
@middlewares.on(:start_step, step_config.name, resolved_arguments, @context)
|
|
68
|
+
completed = false
|
|
69
|
+
begin
|
|
70
|
+
result = if step_config.interrupt?
|
|
71
|
+
handle_interrupt_step(step_config)
|
|
72
|
+
elsif step_config.async? && !@context.inline_async_execution
|
|
73
|
+
handle_async_step(step_config)
|
|
74
|
+
else
|
|
75
|
+
execute_step_with_retry(step_config, resolved_arguments)
|
|
76
|
+
end
|
|
77
|
+
completed = true
|
|
78
|
+
if result.is_a?(RubyReactor::Failure)
|
|
79
|
+
@middlewares.on(:failed_step, step_config.name, result, @context)
|
|
80
|
+
else
|
|
81
|
+
@middlewares.on(:complete_step, step_config.name, result, @context)
|
|
82
|
+
end
|
|
83
|
+
result
|
|
84
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
85
|
+
@middlewares.on(:failed_step, step_config.name, e, @context) unless completed
|
|
86
|
+
raise
|
|
70
87
|
end
|
|
71
88
|
end
|
|
72
89
|
|
|
@@ -95,24 +112,26 @@ module RubyReactor
|
|
|
95
112
|
)
|
|
96
113
|
end
|
|
97
114
|
|
|
98
|
-
def execute_step_with_retry(step_config)
|
|
115
|
+
def execute_step_with_retry(step_config, resolved_arguments = nil)
|
|
116
|
+
resolved_arguments ||= resolve_arguments(step_config)
|
|
99
117
|
result = @retry_manager.execute_with_retry(step_config, @reactor_class) do
|
|
100
|
-
safe_execute_step_sync(step_config)
|
|
118
|
+
safe_execute_step_sync(step_config, resolved_arguments)
|
|
101
119
|
end
|
|
102
120
|
|
|
103
121
|
unless result.is_a?(RetryQueuedResult) || result.is_a?(RubyReactor::AsyncResult)
|
|
104
|
-
resolved_arguments = resolve_arguments(step_config)
|
|
105
122
|
@result_handler.handle_step_result(step_config, result, resolved_arguments)
|
|
106
123
|
end
|
|
107
124
|
|
|
108
125
|
result
|
|
109
126
|
end
|
|
110
127
|
|
|
111
|
-
def safe_execute_step_sync(step_config)
|
|
112
|
-
resolved_arguments
|
|
113
|
-
execute_step_sync_without_result_handling(step_config)
|
|
114
|
-
|
|
115
|
-
|
|
128
|
+
def safe_execute_step_sync(step_config, resolved_arguments = nil)
|
|
129
|
+
resolved_arguments ||= resolve_arguments(step_config)
|
|
130
|
+
execute_step_sync_without_result_handling(step_config, resolved_arguments)
|
|
131
|
+
rescue Error::InputValidationError
|
|
132
|
+
# Validation failures are not retryable and must surface as a structured
|
|
133
|
+
# InputValidationError (with field_errors), so let them propagate.
|
|
134
|
+
raise
|
|
116
135
|
rescue StandardError => e
|
|
117
136
|
# Identify redacted inputs
|
|
118
137
|
redact_inputs = @reactor_class.inputs.select { |_, config| config[:redact] }.keys
|
|
@@ -127,7 +146,7 @@ module RubyReactor
|
|
|
127
146
|
)
|
|
128
147
|
end
|
|
129
148
|
|
|
130
|
-
def execute_step_sync(step_config)
|
|
149
|
+
def execute_step_sync(step_config, resolved_arguments = nil)
|
|
131
150
|
@context.with_step(step_config.name) do
|
|
132
151
|
# Check conditions and guards
|
|
133
152
|
unless step_config.should_run?(@context)
|
|
@@ -136,7 +155,7 @@ module RubyReactor
|
|
|
136
155
|
end
|
|
137
156
|
|
|
138
157
|
# Resolve arguments
|
|
139
|
-
resolved_arguments
|
|
158
|
+
resolved_arguments ||= resolve_arguments(step_config)
|
|
140
159
|
|
|
141
160
|
# Validate arguments if validator is defined
|
|
142
161
|
validate_step_arguments(step_config, resolved_arguments)
|
|
@@ -150,7 +169,7 @@ module RubyReactor
|
|
|
150
169
|
end
|
|
151
170
|
|
|
152
171
|
# Execute step without handling the result (used during retries)
|
|
153
|
-
def execute_step_sync_without_result_handling(step_config)
|
|
172
|
+
def execute_step_sync_without_result_handling(step_config, resolved_arguments = nil)
|
|
154
173
|
@context.with_step(step_config.name) do
|
|
155
174
|
# Check conditions and guards
|
|
156
175
|
unless step_config.should_run?(@context)
|
|
@@ -159,7 +178,7 @@ module RubyReactor
|
|
|
159
178
|
end
|
|
160
179
|
|
|
161
180
|
# Resolve arguments
|
|
162
|
-
resolved_arguments
|
|
181
|
+
resolved_arguments ||= resolve_arguments(step_config)
|
|
163
182
|
|
|
164
183
|
yield resolved_arguments if block_given?
|
|
165
184
|
|
|
@@ -181,6 +200,9 @@ module RubyReactor
|
|
|
181
200
|
context_to_serialize = @context.root_context || @context
|
|
182
201
|
reactor_class_name = context_to_serialize.reactor_class.name
|
|
183
202
|
|
|
203
|
+
# Inject OTel context before serialization
|
|
204
|
+
@middlewares.on(:before_async_enqueue, context_to_serialize)
|
|
205
|
+
|
|
184
206
|
serialized_context = ContextSerializer.serialize(context_to_serialize)
|
|
185
207
|
|
|
186
208
|
configuration.async_router.perform_async(
|
|
@@ -222,11 +244,12 @@ module RubyReactor
|
|
|
222
244
|
validation_result = step_config.args_validator.call(resolved_arguments)
|
|
223
245
|
return if validation_result.success?
|
|
224
246
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
247
|
+
# Stamp step attribution so the resulting Failure can say WHERE the
|
|
248
|
+
# validation failed, not just what was invalid.
|
|
249
|
+
error = validation_result.error
|
|
250
|
+
error.step_name = step_config.name
|
|
251
|
+
error.step_arguments = resolved_arguments
|
|
252
|
+
raise error
|
|
230
253
|
end
|
|
231
254
|
|
|
232
255
|
def resolve_arguments(step_config)
|