ruby_reactor 0.2.0 → 0.3.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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +132 -0
  3. data/Rakefile +2 -2
  4. data/documentation/data_pipelines.md +90 -84
  5. data/documentation/testing.md +812 -0
  6. data/lib/ruby_reactor/configuration.rb +1 -1
  7. data/lib/ruby_reactor/context.rb +13 -5
  8. data/lib/ruby_reactor/context_serializer.rb +70 -4
  9. data/lib/ruby_reactor/dsl/map_builder.rb +6 -2
  10. data/lib/ruby_reactor/dsl/reactor.rb +3 -2
  11. data/lib/ruby_reactor/error/step_failure_error.rb +5 -2
  12. data/lib/ruby_reactor/executor/result_handler.rb +9 -2
  13. data/lib/ruby_reactor/executor/retry_manager.rb +26 -8
  14. data/lib/ruby_reactor/executor/step_executor.rb +24 -99
  15. data/lib/ruby_reactor/executor.rb +3 -13
  16. data/lib/ruby_reactor/map/collector.rb +72 -33
  17. data/lib/ruby_reactor/map/dispatcher.rb +162 -0
  18. data/lib/ruby_reactor/map/element_executor.rb +103 -114
  19. data/lib/ruby_reactor/map/execution.rb +18 -4
  20. data/lib/ruby_reactor/map/helpers.rb +4 -3
  21. data/lib/ruby_reactor/map/result_enumerator.rb +105 -0
  22. data/lib/ruby_reactor/reactor.rb +174 -16
  23. data/lib/ruby_reactor/rspec/helpers.rb +17 -0
  24. data/lib/ruby_reactor/rspec/matchers.rb +256 -0
  25. data/lib/ruby_reactor/rspec/step_executor_patch.rb +85 -0
  26. data/lib/ruby_reactor/rspec/test_subject.rb +625 -0
  27. data/lib/ruby_reactor/rspec.rb +18 -0
  28. data/lib/ruby_reactor/{async_router.rb → sidekiq_adapter.rb} +15 -10
  29. data/lib/ruby_reactor/sidekiq_workers/worker.rb +1 -3
  30. data/lib/ruby_reactor/step/compose_step.rb +0 -1
  31. data/lib/ruby_reactor/step/map_step.rb +52 -27
  32. data/lib/ruby_reactor/storage/redis_adapter.rb +59 -0
  33. data/lib/ruby_reactor/template/dynamic_source.rb +32 -0
  34. data/lib/ruby_reactor/version.rb +1 -1
  35. data/lib/ruby_reactor/web/api.rb +32 -24
  36. data/lib/ruby_reactor.rb +70 -10
  37. metadata +12 -3
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubyReactor
4
+ # rubocop:disable Metrics/ClassLength
4
5
  class Reactor
5
6
  include RubyReactor::Dsl::Reactor
6
7
 
@@ -64,34 +65,71 @@ module RubyReactor
64
65
  def initialize(context = {})
65
66
  @context = context
66
67
  @result = :unexecuted
67
- @undo_trace = []
68
- @execution_trace = []
68
+
69
+ if @context.is_a?(Context)
70
+ @execution_trace = @context.execution_trace || []
71
+ @undo_trace = @execution_trace.select { |e| e[:type] == :undo }
72
+ @result = reconstruct_result
73
+ else
74
+ @undo_trace = []
75
+ @execution_trace = []
76
+ end
69
77
  end
70
78
 
79
+ # rubocop:disable Metrics/MethodLength
71
80
  def run(inputs = {})
72
- if self.class.async?
73
- # For async reactors, enqueue the job and return immediately
74
- context = Context.new(inputs, self.class)
75
- serialized_context = ContextSerializer.serialize(context)
76
- configuration.async_router.perform_async(serialized_context)
81
+ # For all reactors, initialize context first to capture execution ID
82
+ @context = @context.is_a?(Context) ? @context : Context.new(inputs, self.class)
83
+
84
+ # Validate inputs
85
+ validation_result = self.class.validate_inputs(inputs)
86
+ if validation_result.failure?
87
+ @result = validation_result
88
+ @context.status = "failed"
89
+ @context.failure_reason = {
90
+ message: validation_result.error.message,
91
+ validation_errors: validation_result.error.field_errors
92
+ }
93
+ save_context
94
+ return validation_result
95
+ end
96
+
97
+ if self.class.async? && !@context.inline_async_execution
98
+ # For async reactors, queue a job for the whole reactor
99
+ @context.status = :running
100
+ save_context
101
+
102
+ serialized_context = ContextSerializer.serialize(@context)
103
+ @result = configuration.async_router.perform_async(serialized_context, self.class.name,
104
+ intermediate_results: @context.intermediate_results)
105
+
106
+ # Even if it's an AsyncResult, it might have finished inline (e.g. Sidekiq::Testing.inline!)
107
+ # Check storage to see if it's already finished or paused (interrupted).
108
+ begin
109
+ reloaded = self.class.find(@context.context_id)
110
+ if reloaded.finished? || reloaded.context.status.to_s == "paused"
111
+ @context = reloaded.context
112
+ @result = reloaded.result
113
+ @execution_trace = reloaded.execution_trace
114
+ @undo_trace = reloaded.undo_trace
115
+ return @result
116
+ end
117
+ rescue StandardError
118
+ # Ignore if not found or other errors during reload check
119
+ end
120
+
77
121
  else
78
122
  # For sync reactors (potentially with async steps), execute normally
79
123
  context = @context.is_a?(Context) ? @context : nil
80
124
  executor = Executor.new(self.class, inputs, context)
81
125
  @result = executor.execute
82
-
83
126
  @context = executor.context
84
-
85
- # Merge traces
86
- @undo_trace = executor.undo_trace
87
127
  @execution_trace = executor.execution_trace
88
-
89
- # If execution returned an AsyncResult (from step-level async), return it
90
- return @result if @result.is_a?(RubyReactor::AsyncResult)
91
-
92
- @result
128
+ @undo_trace = executor.undo_trace
93
129
  end
130
+ @result
94
131
  end
132
+ # rubocop:enable Metrics/MethodLength, Metrics/AbcSize
95
133
 
96
134
  def continue(payload:, step_name:, idempotency_key: nil)
97
135
  _ = idempotency_key
@@ -178,6 +216,125 @@ module RubyReactor
178
216
  raise Error::DependencyError, "Dependency graph contains cycles"
179
217
  end
180
218
 
219
+ def reconstruct_result
220
+ case @context.status.to_s
221
+ when "completed" then reconstruct_success_result
222
+ when "failed" then reconstruct_failure_result
223
+ when "paused" then reconstruct_paused_result
224
+ else :unexecuted
225
+ end
226
+ end
227
+
228
+ def reconstruct_success_result
229
+ rs = self.class.respond_to?(:returns) ? self.class.returns : nil
230
+ val = if rs
231
+ @context.intermediate_results[rs.to_sym] || @context.intermediate_results[rs.to_s]
232
+ else
233
+ find_last_step_result
234
+ end
235
+ Success.new(val)
236
+ end
237
+
238
+ def find_last_step_result
239
+ last_run = @execution_trace.reverse.find { |e| e[:type] == :run || e["type"] == "run" }
240
+ return unless last_run
241
+
242
+ step_name = last_run[:step] || last_run["step"]
243
+ @context.intermediate_results[step_name.to_sym] || @context.intermediate_results[step_name.to_s]
244
+ end
245
+
246
+ def reconstruct_failure_result
247
+ reason = @context.failure_reason || {}
248
+ return reason if reason.is_a?(RubyReactor::Failure)
249
+
250
+ # Use string keys preferred, fallback to symbol
251
+ r = ->(k) { reason[k.to_s] || reason[k.to_sym] }
252
+
253
+ Failure.new(
254
+ r[:message],
255
+ step_name: r[:step_name],
256
+ inputs: r[:inputs] || {},
257
+ backtrace: r[:backtrace],
258
+ reactor_name: r[:reactor_name],
259
+ step_arguments: r[:step_arguments] || {},
260
+ exception_class: r[:exception_class],
261
+ file_path: r[:file_path],
262
+ line_number: r[:line_number],
263
+ code_snippet: r[:code_snippet],
264
+ validation_errors: r[:validation_errors],
265
+ retryable: r[:retryable],
266
+ invalid_payload: r[:invalid_payload]
267
+ )
268
+ end
269
+
270
+ def reconstruct_paused_result
271
+ InterruptResult.new(
272
+ execution_id: @context.context_id,
273
+ intermediate_results: @context.intermediate_results
274
+ )
275
+ end
276
+
277
+ def initialize_and_validate_run?(inputs)
278
+ # For all reactors, initialize context first to capture execution ID
279
+ @context = @context.is_a?(Context) ? @context : Context.new(inputs, self.class)
280
+
281
+ validation_result = self.class.validate_inputs(inputs)
282
+ if validation_result.failure?
283
+ handle_validation_failure(validation_result)
284
+ return false
285
+ end
286
+ true
287
+ end
288
+
289
+ def handle_validation_failure(result)
290
+ @result = result
291
+ @context.status = "failed"
292
+ @context.failure_reason = {
293
+ message: result.error.message,
294
+ validation_errors: result.error.field_errors
295
+ }
296
+ save_context
297
+ end
298
+
299
+ def perform_async_run
300
+ @context.status = :running
301
+ save_context
302
+
303
+ serialized_context = ContextSerializer.serialize(@context)
304
+ @result = configuration.async_router.perform_async(serialized_context, self.class.name,
305
+ intermediate_results: @context.intermediate_results)
306
+
307
+ check_for_inline_completion
308
+ end
309
+
310
+ def check_for_inline_completion
311
+ # Even if it's an AsyncResult, it might have finished inline (e.g. Sidekiq::Testing.inline!)
312
+ # Check storage to see if it's already finished or paused (interrupted).
313
+ reloaded = self.class.find(@context.context_id)
314
+ if reloaded.finished? || reloaded.context.status.to_s == "paused"
315
+ update_state_from_reloaded(reloaded)
316
+ @result
317
+ end
318
+ rescue StandardError
319
+ # Ignore if not found or other errors during reload check
320
+ end
321
+
322
+ def update_state_from_reloaded(reloaded)
323
+ @context = reloaded.context
324
+ @result = reloaded.result
325
+ @execution_trace = reloaded.execution_trace
326
+ @undo_trace = reloaded.undo_trace
327
+ end
328
+
329
+ def perform_sync_run(inputs)
330
+ context = @context.is_a?(Context) ? @context : nil
331
+ executor = Executor.new(self.class, inputs, context)
332
+ @result = executor.execute
333
+ @context = executor.context
334
+ @execution_trace = executor.execution_trace
335
+ @undo_trace = executor.undo_trace
336
+ end
337
+
181
338
  def validate_continue_step!(step_name)
182
339
  return if step_name.to_s == @context.current_step.to_s
183
340
 
@@ -258,4 +415,5 @@ module RubyReactor
258
415
  storage.store_context(@context.context_id, serialized_context, reactor_class_name)
259
416
  end
260
417
  end
418
+ # rubocop:enable Metrics/ClassLength
261
419
  end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ module RSpec
5
+ module Helpers
6
+ def test_reactor(reactor_class, inputs, context: {}, async: nil, process_jobs: true)
7
+ TestSubject.new(
8
+ reactor_class: reactor_class,
9
+ inputs: inputs,
10
+ context: context,
11
+ async: async,
12
+ process_jobs: process_jobs
13
+ )
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,256 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ module RSpec
5
+ module Matchers
6
+ # rubocop:disable Metrics/BlockLength
7
+ ::RSpec::Matchers.define :be_success do
8
+ match do |subject|
9
+ subject.ensure_executed!
10
+ subject.success?
11
+ end
12
+
13
+ failure_message do |subject|
14
+ result = subject.result
15
+ if result&.failure?
16
+ format_failure_message(result)
17
+ else
18
+ "expected reactor to be success, but failed (Status: #{subject.reactor_instance.context.status})"
19
+ end
20
+ end
21
+
22
+ def format_failure_message(error)
23
+ # Safely extract values
24
+ err_msg = error.respond_to?(:error) ? error.error.to_s : error.to_s
25
+ ex_class = error.respond_to?(:exception_class) ? error.exception_class : nil
26
+ step = error.respond_to?(:step_name) ? error.step_name : nil
27
+ file = error.respond_to?(:file_path) ? error.file_path : nil
28
+ line = error.respond_to?(:line_number) ? error.line_number : nil
29
+ snippet = error.respond_to?(:code_snippet) ? error.code_snippet : nil
30
+ backtrace = error.respond_to?(:backtrace) ? error.backtrace : nil
31
+
32
+ lines = []
33
+ lines << "Error: #{ex_class || "UnknownError"}"
34
+ lines << err_msg.to_s
35
+ lines << "Step: :#{step}" if step
36
+ lines << "File: #{file}:#{line}" if file
37
+
38
+ append_snippet(lines, snippet)
39
+ append_backtrace(lines, backtrace)
40
+
41
+ lines.join("\n")
42
+ end
43
+
44
+ def append_snippet(lines, snippet)
45
+ return unless snippet.is_a?(Array) && !snippet.empty?
46
+
47
+ lines << ""
48
+ snippet.each do |s|
49
+ prefix = s[:target] ? "--> " : " "
50
+ lines << "#{prefix}#{s[:content]}"
51
+ end
52
+ end
53
+
54
+ def append_backtrace(lines, backtrace)
55
+ return unless backtrace && !backtrace.empty?
56
+
57
+ lines << ""
58
+ lines << "Backtrace:"
59
+ lines << backtrace.take(10).map { |l| "- #{l}" }
60
+ end
61
+ end
62
+
63
+ ::RSpec::Matchers.define :be_failure do
64
+ match do |subject|
65
+ subject.ensure_executed!
66
+ subject.failure?
67
+ end
68
+
69
+ failure_message do |_subject|
70
+ "expected reactor to be failure, but succeeded"
71
+ end
72
+ end
73
+
74
+ ::RSpec::Matchers.define :have_run_step do |step_name|
75
+ match do |subject|
76
+ subject.ensure_executed!
77
+ @trace = subject.reactor_instance.context.execution_trace
78
+ @entry = @trace.find { |t| t[:step].to_s == step_name.to_s }
79
+
80
+ return false unless @entry
81
+
82
+ matches_result?(subject, step_name) && matches_order?
83
+ end
84
+
85
+ def matches_result?(subject, step_name)
86
+ return true unless @check_result
87
+
88
+ actual_result = subject.step_result(step_name)
89
+ if @expected_result.is_a?(Regexp)
90
+ actual_result.to_s.match?(@expected_result)
91
+ else
92
+ values_match?(@expected_result, actual_result)
93
+ end
94
+ end
95
+
96
+ def matches_order?
97
+ return true unless @after_step
98
+
99
+ after_index = @trace.index(@entry)
100
+ before_entry = @trace.find { |t| t[:step].to_s == @after_step.to_s }
101
+
102
+ return false unless before_entry
103
+
104
+ after_index > @trace.index(before_entry)
105
+ end
106
+
107
+ chain :returning do |value|
108
+ @check_result = true
109
+ @expected_result = value
110
+ end
111
+
112
+ chain :after do |step|
113
+ @after_step = step
114
+ end
115
+
116
+ failure_message do |subject|
117
+ msg = "expected reactor to have run step :#{step_name}"
118
+ if @check_result
119
+ actual_result = subject.step_result(step_name)
120
+ msg += " returning #{@expected_result.inspect}, but returned #{actual_result.inspect}"
121
+ end
122
+ msg += " after :#{@after_step}" if @after_step
123
+ msg
124
+ end
125
+ end
126
+
127
+ ::RSpec::Matchers.define :have_retried_step do |step_name|
128
+ match do |subject|
129
+ subject.ensure_executed!
130
+ attempts = subject.reactor_instance.context.retry_context.attempts_for_step(step_name)
131
+ retries = attempts - 1
132
+
133
+ if @expected_retries
134
+ retries == @expected_retries
135
+ else
136
+ retries.positive?
137
+ end
138
+ end
139
+
140
+ chain :times do |count|
141
+ @expected_retries = count
142
+ end
143
+
144
+ failure_message do |_subject|
145
+ msg = "expected reactor to have retried step :#{step_name}"
146
+ msg += " #{@expected_retries} times" if @expected_retries
147
+ msg
148
+ end
149
+ end
150
+
151
+ ::RSpec::Matchers.define :have_validation_error do |field|
152
+ match do |subject|
153
+ subject.ensure_executed!
154
+ return false unless subject.failure?
155
+
156
+ # Try to get validation errors from failure reason
157
+ reason = subject.reactor_instance.context.failure_reason || {}
158
+
159
+ # If failure is InputValidationError, it might be serialized differently
160
+ # Or stored in validation_errors key
161
+ errors = reason["validation_errors"] || reason[:validation_errors]
162
+
163
+ if errors
164
+ errors.key?(field.to_s) || errors.key?(field.to_sym)
165
+ else
166
+ false
167
+ end
168
+ end
169
+
170
+ failure_message do |_subject|
171
+ "expected reactor to have validation error on :#{field}"
172
+ end
173
+ end
174
+
175
+ # Matcher to check if reactor is paused at an interrupt
176
+ ::RSpec::Matchers.define :be_paused do
177
+ match do |subject|
178
+ subject.ensure_executed!
179
+ subject.paused?
180
+ end
181
+
182
+ failure_message do |subject|
183
+ "expected reactor to be paused, but status was #{subject.reactor_instance.context.status}"
184
+ end
185
+
186
+ failure_message_when_negated do |_subject|
187
+ "expected reactor not to be paused, but it is"
188
+ end
189
+ end
190
+
191
+ # Matcher to check if reactor is paused at a specific interrupt step
192
+ # Works with both single and multiple concurrent interrupts
193
+ ::RSpec::Matchers.define :be_paused_at do |*step_names|
194
+ match do |subject|
195
+ subject.ensure_executed!
196
+ return false unless subject.paused?
197
+
198
+ ready_steps = subject.ready_interrupt_steps
199
+ step_names.all? { |name| ready_steps.include?(name.to_sym) }
200
+ end
201
+
202
+ failure_message do |subject|
203
+ if subject.paused?
204
+ ready_steps = subject.ready_interrupt_steps
205
+ if step_names.size == 1
206
+ "expected reactor to be paused at :#{step_names.first}, " \
207
+ "but ready interrupt steps are: #{ready_steps.inspect}"
208
+ else
209
+ "expected reactor to be paused at #{step_names.map { |s| ":#{s}" }.join(", ")}, " \
210
+ "but ready interrupt steps are: #{ready_steps.inspect}"
211
+ end
212
+ else
213
+ "expected reactor to be paused at #{step_names.map { |s| ":#{s}" }.join(", ")}, " \
214
+ "but status was #{subject.reactor_instance.context.status}"
215
+ end
216
+ end
217
+
218
+ failure_message_when_negated do |_subject|
219
+ "expected reactor not to be paused at #{step_names.map { |s| ":#{s}" }.join(", ")}, but it is"
220
+ end
221
+ end
222
+
223
+ # Matcher to check the exact set of ready interrupt steps
224
+ ::RSpec::Matchers.define :have_ready_interrupts do |*expected_steps|
225
+ match do |subject|
226
+ subject.ensure_executed!
227
+ return false unless subject.paused?
228
+
229
+ actual_steps = subject.ready_interrupt_steps.sort
230
+ expected = expected_steps.map(&:to_sym).sort
231
+ actual_steps == expected
232
+ end
233
+
234
+ failure_message do |subject|
235
+ if subject.paused?
236
+ actual_steps = subject.ready_interrupt_steps
237
+ "expected ready interrupt steps to be #{expected_steps.map { |s| ":#{s}" }}, " \
238
+ "but got #{actual_steps.inspect}"
239
+ else
240
+ "expected reactor to be paused with ready interrupt steps, " \
241
+ "but status was #{subject.reactor_instance.context.status}"
242
+ end
243
+ end
244
+
245
+ failure_message_when_negated do |subject|
246
+ actual_steps = subject.ready_interrupt_steps
247
+ "expected ready interrupt steps not to be #{expected_steps.map { |s| ":#{s}" }}, " \
248
+ "but it was #{actual_steps.inspect}"
249
+ end
250
+ end
251
+
252
+ # Add more matchers as per plan
253
+ # rubocop:enable Metrics/BlockLength
254
+ end
255
+ end
256
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_reactor"
4
+
5
+ module RubyReactor
6
+ module RSpec
7
+ # Patch StepExecutor to handle inline async execution during tests.
8
+ # This ensures that when a worker runs inline (e.g. Sidekiq::Testing.inline!),
9
+ # the calling executor can pick up the state change immediately and continue,
10
+ # preventing stalled execution in nested async scenarios.
11
+ module StepExecutorPatch
12
+ def execute_step(step_config)
13
+ # 1. Call original implementation
14
+ result = super
15
+
16
+ # 2. Add test-specific logic for inline async execution
17
+ # Only interfere if we got an AsyncResult and we are in a testing environment that supports inline execution
18
+ if should_check_inline_completion?(result)
19
+ # Check if it finished or paused inline (e.g. Sidekiq::Testing.inline!)
20
+ refresh_context_from_storage
21
+
22
+ # If the step itself now has a result, it means it ran inline
23
+ if @context.has_result?(step_config.name)
24
+ # If the step failed, we should return failure
25
+ return reconstruct_failure(@context.failure_reason) if @context.failed?
26
+
27
+ return nil # Continue to next step
28
+ end
29
+
30
+ # If the overall reactor finished or paused for other reasons (e.g. error in worker)
31
+ if @context.finished? || @context.status.to_s == "paused"
32
+ return reconstruct_failure(@context.failure_reason) if @context.failed?
33
+
34
+ if @context.status.to_s == "paused"
35
+ return RubyReactor::InterruptResult.new(
36
+ execution_id: @context.context_id,
37
+ intermediate_results: @context.intermediate_results
38
+ )
39
+ end
40
+ return nil # Finished successfully
41
+ end
42
+ end
43
+
44
+ # Return original result if no intervention needed
45
+ result
46
+ end
47
+
48
+ private
49
+
50
+ def refresh_context_from_storage
51
+ storage = RubyReactor::Configuration.instance.storage_adapter
52
+ reactor_class_name = @reactor_class.name
53
+ serialized = storage.retrieve_context(@context.context_id, reactor_class_name)
54
+ return unless serialized
55
+
56
+ reloaded_context = Context.deserialize_from_retry(serialized)
57
+
58
+ # Update local context state
59
+ @context.status = reloaded_context.status
60
+ @context.failure_reason = reloaded_context.failure_reason
61
+ @context.cancelled = reloaded_context.cancelled
62
+ @context.cancellation_reason = reloaded_context.cancellation_reason
63
+ @context.intermediate_results.merge!(reloaded_context.intermediate_results)
64
+ @context.execution_trace = reloaded_context.execution_trace
65
+ @context.private_data.merge!(reloaded_context.private_data)
66
+ @context.retry_context = reloaded_context.retry_context
67
+ @context.composed_contexts.merge!(reloaded_context.composed_contexts)
68
+ @context.map_operations.merge!(reloaded_context.map_operations)
69
+ @context.map_metadata = reloaded_context.map_metadata
70
+
71
+ # Also need to mark step as completed in dependency graph
72
+ reloaded_context.intermediate_results.each_key do |step_name|
73
+ @dependency_graph.complete_step(step_name.to_sym)
74
+ end
75
+ end
76
+
77
+ def should_check_inline_completion?(result)
78
+ return false unless result.is_a?(RubyReactor::AsyncResult) || result.is_a?(RubyReactor::RetryQueuedResult)
79
+ return true if defined?(Sidekiq::Testing) && Sidekiq::Testing.inline?
80
+
81
+ false
82
+ end
83
+ end
84
+ end
85
+ end