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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/.rubocop.yml +1 -0
  4. data/CHANGELOG.md +14 -0
  5. data/README.md +153 -18
  6. data/lib/ruby_reactor/configuration.rb +13 -1
  7. data/lib/ruby_reactor/context.rb +2 -1
  8. data/lib/ruby_reactor/context_serializer.rb +1 -3
  9. data/lib/ruby_reactor/dsl/interrupt_builder.rb +18 -2
  10. data/lib/ruby_reactor/dsl/lockable.rb +19 -28
  11. data/lib/ruby_reactor/dsl/reactor.rb +44 -9
  12. data/lib/ruby_reactor/dsl/step_builder.rb +25 -39
  13. data/lib/ruby_reactor/dsl/validation_helpers.rb +34 -0
  14. data/lib/ruby_reactor/error/input_validation_error.rb +4 -0
  15. data/lib/ruby_reactor/executor/compensation_manager.rb +75 -47
  16. data/lib/ruby_reactor/executor/result_handler.rb +35 -8
  17. data/lib/ruby_reactor/executor/retry_manager.rb +15 -5
  18. data/lib/ruby_reactor/executor/step_executor.rb +46 -23
  19. data/lib/ruby_reactor/executor.rb +188 -49
  20. data/lib/ruby_reactor/map/collector.rb +4 -4
  21. data/lib/ruby_reactor/map/element_executor.rb +15 -1
  22. data/lib/ruby_reactor/map/helpers.rb +17 -4
  23. data/lib/ruby_reactor/middleware.rb +13 -0
  24. data/lib/ruby_reactor/middleware_runner.rb +29 -0
  25. data/lib/ruby_reactor/open_telemetry.rb +647 -0
  26. data/lib/ruby_reactor/rate_limit.rb +28 -0
  27. data/lib/ruby_reactor/rate_limit_registry.rb +51 -0
  28. data/lib/ruby_reactor/reactor.rb +1 -0
  29. data/lib/ruby_reactor/rspec/test_subject.rb +0 -1
  30. data/lib/ruby_reactor/sidekiq_adapter.rb +7 -21
  31. data/lib/ruby_reactor/sidekiq_workers/worker.rb +4 -0
  32. data/lib/ruby_reactor/step/map_step.rb +25 -33
  33. data/lib/ruby_reactor/validation/base.rb +4 -1
  34. data/lib/ruby_reactor/validation/input_validator.rb +4 -2
  35. data/lib/ruby_reactor/validation/schema_builder.rb +82 -0
  36. data/lib/ruby_reactor/version.rb +1 -1
  37. data/lib/ruby_reactor/web/coordination_serializer.rb +12 -18
  38. data/teley/Dockerfile +60 -0
  39. metadata +6 -3
  40. data/lib/ruby_reactor/map/execution.rb +0 -101
  41. 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
- compensate_result = if step_config.compensate_block
56
- step_config.compensate_block.call(error, arguments, @context)
57
- elsif step_config.has_impl?
58
- step_config.impl.compensate(error, arguments, @context)
59
- else
60
- RubyReactor.Success() # Default compensation
61
- end
62
-
63
- # Ensure we have a value to log
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
- @context.execution_trace << {
73
- type: :compensate,
74
- step: step_config.name,
75
- timestamp: Time.now,
76
- result: logged_result,
77
- arguments: arguments
78
- }
79
- @undo_trace << { type: :compensation, step: step_config.name, error: error, arguments: arguments }
80
- compensate_result
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
- undo_result = if step_config.undo_block
85
- step_config.undo_block.call(result.value, arguments, @context)
86
- elsif step_config.has_impl?
87
- step_config.impl.undo(result.value, arguments, @context)
88
- else
89
- RubyReactor.Success()
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
- undo_result
108
+ RubyReactor.Success()
99
109
  end
100
110
 
101
- @context.execution_trace << { type: :undo, step: step_config.name, timestamp: Time.now, result: logged_result,
102
- arguments: arguments }
103
- undo_result
104
- rescue StandardError => e
105
- # Log undo failure but don't halt the rollback process
106
- @context.execution_trace << { type: :undo_failure, step: step_config.name, timestamp: Time.now,
107
- error: e.message }
108
- RubyReactor.Failure(e)
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
- # Preserve validation errors as-is for proper error handling
37
- RubyReactor.Failure(error, validation_errors: error.field_errors)
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
- raise Error::StepFailureError.new(
184
- "Step '#{step_config.name}' output validation failed: #{output_validation_result.error.message}",
185
- step: step_config.name,
186
- context: @context,
187
- step_arguments: resolved_arguments
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
- if step_config.interrupt?
65
- handle_interrupt_step(step_config)
66
- elsif step_config.async? && !@context.inline_async_execution
67
- handle_async_step(step_config)
68
- else
69
- execute_step_with_retry(step_config)
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) do |args|
114
- resolved_arguments = args
115
- end
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 = resolve_arguments(step_config)
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 = resolve_arguments(step_config)
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
- raise Error::StepFailureError.new(
226
- "Step '#{step_config.name}' argument validation failed: #{validation_result.error.message}",
227
- step: step_config.name,
228
- context: @context
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)